From 227e1bb2ddf2b93f89e4bc816c473d99e245a814 Mon Sep 17 00:00:00 2001 From: Olderestin Date: Mon, 8 Sep 2025 19:56:09 +0300 Subject: [PATCH 01/18] feat(yatgbot): Implement `fsm` --- yatgbot/fsm/fsm.go | 163 +++++++++++++++++++++++++++++++++++++++++ yatgbot/fsm/userfsm.go | 42 +++++++++++ 2 files changed, 205 insertions(+) create mode 100644 yatgbot/fsm/fsm.go create mode 100644 yatgbot/fsm/userfsm.go diff --git a/yatgbot/fsm/fsm.go b/yatgbot/fsm/fsm.go new file mode 100644 index 0000000..4f3a074 --- /dev/null +++ b/yatgbot/fsm/fsm.go @@ -0,0 +1,163 @@ +package fsm + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yacache" + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" + "github.com/redis/go-redis/v9" +) + +type State interface { + StateName() string +} + +type BaseState[T State] struct{} + +func (BaseState[T]) StateName() string { + var zero T + t := reflect.TypeOf(zero) + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + + return t.Name() +} + +type EmptyState struct { + BaseState[EmptyState] +} + +type stateDataMarshalled string + +type StateAndData struct { + State string `json:"state"` + StateData string `json:"state_data"` +} + +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 +} + +type DefaultFSMStorage struct { + storage yacache.Cache[*redis.Client] + defaultState State +} + +func NewDefaultFSMStorage( + storage yacache.Cache[*redis.Client], + defaultState State, +) *DefaultFSMStorage { + return &DefaultFSMStorage{ + storage: storage, + defaultState: defaultState, + } +} + +func (b *DefaultFSMStorage) SetState( + ctx context.Context, + uid string, + stateData State, +) yaerrors.Error { + val, err := json.Marshal(stateData) + + if err != nil { + return yaerrors.FromError( + 500, + err, + "failed to marshal state data", + ) + } + + val, err = json.Marshal(StateAndData{ + State: stateData.StateName(), + StateData: string(val), + }) + + if err != nil { + return yaerrors.FromError( + 500, + err, + "failed to marshal state data", + ) + } + + b.storage.Set(ctx, uid, string(val), 0) + + return nil +} + +func (b *DefaultFSMStorage) GetState( + ctx context.Context, + uid string, +) (string, stateDataMarshalled, yaerrors.Error) { + 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( + 500, + err, + "failed to unmarshal state data map", + ) + } + + state, ok := stateAndData["state"] + + if !ok { + return "", "", yaerrors.FromError( + 404, + fmt.Errorf("state not found"), + "failed to get state", + ) + } + return state, stateDataMarshalled(data), nil +} + +func (b *DefaultFSMStorage) 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( + 500, + err, + "failed to unmarshal state data map", + ) + } + + stateDataMarshalled, ok := stateAndData["state_data"] + + if !ok { + return yaerrors.FromError( + 404, + fmt.Errorf("state data not found"), + "failed to get state data", + ) + } + + if err := json.Unmarshal([]byte(stateDataMarshalled), emptyState); err != nil { + return yaerrors.FromError( + 500, + err, + "failed to unmarshal state data", + ) + } + + return nil +} diff --git a/yatgbot/fsm/userfsm.go b/yatgbot/fsm/userfsm.go new file mode 100644 index 0000000..47be5f7 --- /dev/null +++ b/yatgbot/fsm/userfsm.go @@ -0,0 +1,42 @@ +package fsm + +import ( + "context" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" +) + +type UserFSMStorage struct { + storage FSM + uid string +} + +func NewUserFSMStorage( + storage FSM, + uid string, +) *UserFSMStorage { + return &UserFSMStorage{ + storage: storage, + uid: uid, + } +} + +func (b *UserFSMStorage) SetState( + ctx context.Context, + stateData State, +) yaerrors.Error { + return b.storage.SetState(ctx, b.uid, stateData) +} + +func (b *UserFSMStorage) GetState( + ctx context.Context, +) (string, stateDataMarshalled, yaerrors.Error) { + return b.storage.GetState(ctx, b.uid) +} + +func (b *UserFSMStorage) GetStateData( + stateData stateDataMarshalled, + emptyState State, +) yaerrors.Error { + return b.storage.GetStateData(stateData, emptyState) +} From d72e1d89d36d03cdad7fc8d32b9bb51185a8ad7e Mon Sep 17 00:00:00 2001 From: Olderestin Date: Mon, 8 Sep 2025 19:56:44 +0300 Subject: [PATCH 02/18] feat(yatgbot): Implement `message_queue` --- yatgbot/messagequeue/heap.go | 47 ++++++++++++++++++ yatgbot/messagequeue/queue.go | 90 +++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 yatgbot/messagequeue/heap.go create mode 100644 yatgbot/messagequeue/queue.go diff --git a/yatgbot/messagequeue/heap.go b/yatgbot/messagequeue/heap.go new file mode 100644 index 0000000..43dbfad --- /dev/null +++ b/yatgbot/messagequeue/heap.go @@ -0,0 +1,47 @@ +package messagequeue + +import ( + "time" + + "github.com/gotd/td/telegram/message" + "github.com/gotd/td/tg" +) + +type MessageJob struct { + Priority uint16 + Timestamp time.Time + Text string + Markup tg.ReplyMarkupClass + Sender *message.Sender + To tg.InputPeerClass +} + +type messageHeap []MessageJob + +func (h messageHeap) Len() int { return len(h) } + +func (h messageHeap) Less(i, j int) bool { + if h[i].Priority == h[j].Priority { + return h[i].Timestamp.Before(h[j].Timestamp) + } + + return h[i].Priority < h[j].Priority +} +func (h messageHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } +func (h *messageHeap) Push(x any) { + job, ok := x.(MessageJob) + if !ok { + return + } + + *h = append(*h, job) +} + +func (h *messageHeap) Pop() any { + old := *h + n := len(old) + x := old[n-1] + *h = old[:n-1] + + return x +} diff --git a/yatgbot/messagequeue/queue.go b/yatgbot/messagequeue/queue.go new file mode 100644 index 0000000..7cd6040 --- /dev/null +++ b/yatgbot/messagequeue/queue.go @@ -0,0 +1,90 @@ +package messagequeue + +import ( + "container/heap" + "context" + "sync" + "time" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yalogger" +) + +type Dispatcher struct { + mu sync.Mutex + inputChannel chan MessageJob + priorityQueueChannel chan MessageJob + wg sync.WaitGroup + log yalogger.Logger +} + +func NewDispatcher(ctx context.Context, workerCount int, log yalogger.Logger) *Dispatcher { + dispatcher := &Dispatcher{ + inputChannel: make(chan MessageJob, 100), + priorityQueueChannel: make(chan MessageJob, 100), + log: log, + } + + dispatcher.wg.Add(1) + go dispatcher.reorderMessages(ctx) + + for i := 0; i < workerCount; i++ { + dispatcher.wg.Add(1) + go dispatcher.worker(ctx, i) + } + + return dispatcher +} + +func (d *Dispatcher) reorderMessages(ctx context.Context) { + defer d.wg.Done() + + var pq messageHeap + + heap.Init(&pq) + + for { + select { + case job := <-d.inputChannel: + heap.Push(&pq, job) + case <-ctx.Done(): + return + } + + if pq.Len() > 0 { + job := heap.Pop(&pq).(MessageJob) + d.priorityQueueChannel <- job + } + } +} + +func (d *Dispatcher) worker(ctx context.Context, id int) { + defer d.wg.Done() + + for { + select { + case job := <-d.priorityQueueChannel: + start := time.Now() + + if job.Markup == nil { + if _, err := job.Sender.To(job.To).Text(ctx, job.Text); err != nil { + d.log.Infof("[worker %d] error sending message without markup: %v", id, err) + } + } else { + if _, err := job.Sender.To(job.To).Markup(job.Markup).Text(ctx, job.Text); err != nil { + d.log.Infof("[worker %d] error sending message with markup: %v", id, err) + } + } + + elapsed := time.Since(start) + if wait := time.Second - elapsed; wait > 0 { + time.Sleep(wait) + } + case <-ctx.Done(): + return + } + } +} + +func (d *Dispatcher) Add(job MessageJob) { + d.inputChannel <- job +} From fd94634125bd33d55f222e8c4c7c7273aad887ae Mon Sep 17 00:00:00 2001 From: Olderestin Date: Mon, 8 Sep 2025 19:57:45 +0300 Subject: [PATCH 03/18] feat(yatgbot): Implement base tg bot --- yatgbot/localizer/localizer.go | 80 +++++++++++++++ yatgbot/router/dispatcher.go | 174 +++++++++++++++++++++++++++++++++ yatgbot/router/filter.go | 79 +++++++++++++++ yatgbot/router/middlewares.go | 45 +++++++++ yatgbot/router/router.go | 112 +++++++++++++++++++++ yatgbot/router/schema.go | 22 +++++ yatgbot/router/updates.go | 47 +++++++++ 7 files changed, 559 insertions(+) create mode 100644 yatgbot/localizer/localizer.go create mode 100644 yatgbot/router/dispatcher.go create mode 100644 yatgbot/router/filter.go create mode 100644 yatgbot/router/middlewares.go create mode 100644 yatgbot/router/router.go create mode 100644 yatgbot/router/schema.go create mode 100644 yatgbot/router/updates.go diff --git a/yatgbot/localizer/localizer.go b/yatgbot/localizer/localizer.go new file mode 100644 index 0000000..b9e2ee8 --- /dev/null +++ b/yatgbot/localizer/localizer.go @@ -0,0 +1,80 @@ +package localizer + +import ( + "encoding/json" + "fmt" + "io/fs" + "path/filepath" + "strings" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" +) + +type Localizer struct { + strings map[string]map[string]string + defaultLang string +} + +func NewLocalizer(fsys fs.FS, defaultLang string) (*Localizer, yaerrors.Error) { + loc := &Localizer{ + strings: make(map[string]map[string]string), + defaultLang: defaultLang, + } + + err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + + if !strings.HasSuffix(path, ".json") { + return nil + } + + lang := strings.TrimSuffix(filepath.Base(path), ".json") + + bytes, err := fs.ReadFile(fsys, path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + + var data map[string]string + if err := json.Unmarshal(bytes, &data); err != nil { + return fmt.Errorf("decode %s: %w", path, err) + } + + loc.strings[lang] = data + + return nil + }) + if err != nil { + return nil, yaerrors.FromError(0, err, "failed to walk directory") + } + + return loc, nil +} + +func (l *Localizer) Lang(lang string) func(key string) string { + return func(key string) string { + if val, ok := l.strings[lang][key]; ok { + return val + } + + if val, ok := l.strings[l.defaultLang][key]; ok { + return val + } + + return key + } +} + +func (l *Localizer) T(lang, key string) string { + if val, ok := l.strings[lang][key]; ok { + return val + } + + if val, ok := l.strings[l.defaultLang][key]; ok { + return val + } + + return key +} diff --git a/yatgbot/router/dispatcher.go b/yatgbot/router/dispatcher.go new file mode 100644 index 0000000..84e40de --- /dev/null +++ b/yatgbot/router/dispatcher.go @@ -0,0 +1,174 @@ +package router + +import ( + "context" + "strconv" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" + "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/fsm" + "github.com/gotd/td/tg" +) + +type DispatcherDependecies struct { + userID int64 + chatID int64 + ent tg.Entities + update any + inputPeer tg.InputPeerClass +} + +func (r *Router) dispatch(ctx context.Context, deps DispatcherDependecies) yaerrors.Error { + userFSMStorage := fsm.NewUserFSMStorage( + r.FSMStore, + strconv.FormatInt(deps.chatID, 10), + ) + + r.Log.Debugf("Coming update: %+v\nEntities: %+v", deps.update, deps.ent) + + for _, rt := range r.routes { + ok, err := r.checkFilters( + ctx, + FilterDependecies{ + update: deps.update, + storage: *userFSMStorage, + userID: deps.userID, + }, + rt.filters) + if err != nil { + return yaerrors.FromErrorWithLog(0, err, "failed to apply filters", r.Log) + } + + if !ok { + r.Log.Debugf("Filters not passed for %T", deps.update) + + continue + } + + var lang func(string) string + + if user, ok := deps.ent.Users[deps.userID]; ok && user.LangCode != "" { + lang = r.Localizer.Lang(user.LangCode) + r.Log.Debugf("Using user %d language: %s", deps.userID, user.LangCode) + } + + hdata := &HandlerData{ + Entities: deps.ent, + Sender: r.Sender, + Update: deps.update, + UserID: deps.userID, + Peer: deps.inputPeer, + State: userFSMStorage, + Log: r.Log, + Dispatcher: r.MessageDispatcher, + T: lang, + Client: r.Client, + } + + switch u := deps.update.(type) { + case *tg.Message: + return chainMiddleware(wrapHandler(rt.msgHandler), r.collectMiddlewares()...)(ctx, hdata, u) + case *tg.UpdateBotCallbackQuery: + return chainMiddleware(wrapHandler(rt.cbHandler), r.collectMiddlewares()...)(ctx, hdata, u) + } + } + + for _, sub := range r.sub { + err := sub.dispatch(ctx, deps) + if err != nil { + return err + } + } + + return nil +} + +func (r *Router) checkFilters(ctx context.Context, deps FilterDependecies, local []Filter) (bool, yaerrors.Error) { + if r.parent != nil { + ok, err := r.parent.checkFilters(ctx, deps, nil) + if err != nil || !ok { + return ok, err + } + } + + for _, f := range r.base { + ok, err := f(ctx, deps) + if err != nil || !ok { + return ok, err + } + } + + for _, f := range local { + ok, err := f(ctx, deps) + if err != nil || !ok { + return ok, err + } + } + + return true, nil +} + +func makeInputPeer(p tg.PeerClass, ents tg.Entities) (tg.InputPeerClass, bool) { + switch v := p.(type) { + case *tg.PeerUser: + u, ok := ents.Users[v.UserID] + if !ok { + return nil, false + } + + return &tg.InputPeerUser{ + UserID: v.UserID, + AccessHash: u.AccessHash, + }, true + + case *tg.PeerChat: + return &tg.InputPeerChat{ChatID: v.ChatID}, true + + case *tg.PeerChannel: + c, ok := ents.Channels[v.ChannelID] + if !ok { + return nil, false + } + + return &tg.InputPeerChannel{ + ChannelID: v.ChannelID, + AccessHash: c.AccessHash, + }, true + } + + return nil, false +} + +func getChatID(peer tg.PeerClass, ents tg.Entities) (int64, bool) { + switch v := peer.(type) { + case *tg.PeerUser: + return v.UserID, true + case *tg.PeerChat: + return v.ChatID, true + case *tg.PeerChannel: + c, ok := ents.Channels[v.ChannelID] + if !ok { + return 0, false + } + + return c.ID, true + default: + return 0, false + } +} + +func getUserID(peer tg.PeerClass, fromID tg.PeerClass) (int64, bool) { + switch v := peer.(type) { + case *tg.PeerUser: + return v.UserID, true + + case *tg.PeerChat: + if fromUser, ok := fromID.(*tg.PeerUser); ok { + return fromUser.UserID, true + } + + return 0, false + + default: + return 0, false + } +} diff --git a/yatgbot/router/filter.go b/yatgbot/router/filter.go new file mode 100644 index 0000000..665f05f --- /dev/null +++ b/yatgbot/router/filter.go @@ -0,0 +1,79 @@ +package router + +import ( + "context" + "regexp" + "strings" + + "github.com/gotd/td/tg" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" + "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/fsm" +) + +type Filter func(ctx context.Context, deps FilterDependecies) (bool, yaerrors.Error) + +type FilterDependecies struct { + storage fsm.UserFSMStorage + userID int64 + update any +} + +func StateIs(want ...string) Filter { + wanted := make(map[string]struct{}, len(want)) + + for _, s := range want { + wanted[s] = struct{}{} + } + + return func(ctx context.Context, deps FilterDependecies) (bool, yaerrors.Error) { + state, _, err := deps.storage.GetState(ctx) + if err != nil { + return false, yaerrors.FromError(0, err, "failed to get state for user %d") + } + + _, ok := wanted[state] + + return ok, nil + } +} + +func TextEq(want string) Filter { + return func(_ context.Context, deps FilterDependecies) (bool, yaerrors.Error) { + if m, ok := deps.update.(*tg.Message); ok && m.Message == want { + return true, nil + } + + return false, nil + } +} + +func TextRegex(re *regexp.Regexp) Filter { + return func(_ context.Context, deps FilterDependecies) (bool, yaerrors.Error) { + if m, ok := deps.update.(*tg.Message); ok && re.MatchString(m.Message) { + return true, nil + } + + return false, nil + } +} + +func CallbackEq(data string) Filter { + return func(_ context.Context, deps FilterDependecies) (bool, yaerrors.Error) { + if q, ok := deps.update.(*tg.UpdateBotCallbackQuery); ok && string(q.Data) == data { + return true, nil + } + + return false, nil + } +} + +func CallbackPrefix(prefix string) Filter { + return func(_ context.Context, deps FilterDependecies) (bool, yaerrors.Error) { + if q, ok := deps.update.(*tg.UpdateBotCallbackQuery); ok && strings.HasPrefix(string(q.Data), prefix) { + return true, nil + } + + return false, nil + } +} diff --git a/yatgbot/router/middlewares.go b/yatgbot/router/middlewares.go new file mode 100644 index 0000000..fecfd68 --- /dev/null +++ b/yatgbot/router/middlewares.go @@ -0,0 +1,45 @@ +package router + +import ( + "context" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" +) + +type HandlerNext func(ctx context.Context, handlerData *HandlerData, upd any) yaerrors.Error + +type HandlerMiddleware func(ctx context.Context, handlerData *HandlerData, upd any, next HandlerNext) yaerrors.Error + +func (r *Router) AddMiddleware(mw ...HandlerMiddleware) { + r.middlewares = append(r.middlewares, mw...) +} + +func chainMiddleware(final HandlerNext, middlewares ...HandlerMiddleware) HandlerNext { + for i := len(middlewares) - 1; i >= 0; i-- { + middleware := middlewares[i] + next := final + final = func(ctx context.Context, handlerData *HandlerData, upd any) yaerrors.Error { + return middleware(ctx, handlerData, upd, next) + } + } + + return final +} + +func wrapHandler[T any](h func(context.Context, *HandlerData, *T) yaerrors.Error) HandlerNext { + return func(ctx context.Context, handlerData *HandlerData, upd any) yaerrors.Error { + if t, ok := upd.(*T); ok { + return h(ctx, handlerData, t) + } + + return nil + } +} + +func (r *Router) collectMiddlewares() []HandlerMiddleware { + if r.parent == nil { + return r.middlewares + } + + return append(r.parent.collectMiddlewares(), r.middlewares...) +} diff --git a/yatgbot/router/router.go b/yatgbot/router/router.go new file mode 100644 index 0000000..2c86b3a --- /dev/null +++ b/yatgbot/router/router.go @@ -0,0 +1,112 @@ +package router + +import ( + "context" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" + "github.com/YaCodeDev/GoYaCodeDevUtils/yalogger" + "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/fsm" + "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/localizer" + "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/messagequeue" + "github.com/gotd/td/telegram/message" + "github.com/gotd/td/tg" +) + +type ( + MessageHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.Message) yaerrors.Error + CallbackHandler func(ctx context.Context, handlerData *HandlerData, cb *tg.UpdateBotCallbackQuery) yaerrors.Error +) + +type route struct { + filters []Filter + msgHandler MessageHandler + cbHandler CallbackHandler +} + +type Router struct { + Dependencies + + parent *Router + name string + base []Filter + sub []*Router + routes []*route + middlewares []HandlerMiddleware +} + +type Dependencies struct { + FSMStore fsm.FSM + Log yalogger.Logger + MessageDispatcher *messagequeue.Dispatcher + Localizer *localizer.Localizer + Client *tg.Client + Sender *message.Sender +} + +func New(name string, deps *Dependencies) *Router { + if deps == nil { + deps = &Dependencies{ + FSMStore: nil, + Log: nil, + MessageDispatcher: nil, + Localizer: nil, + Client: nil, + Sender: nil, + } + } + + r := &Router{ + name: name, + Dependencies: *deps, + } + + return r +} + +func (r *Router) Use(f ...Filter) { r.base = append(r.base, f...) } + +func (r *Router) IncludeRouter(subs ...*Router) { + for _, s := range subs { + s.parent = r + + if s.Sender == nil { + s.Sender = r.Sender + } + + if s.FSMStore == nil { + s.FSMStore = r.FSMStore + } + + if s.Log == nil { + s.Log = r.Log + } + + if s.MessageDispatcher == nil { + s.MessageDispatcher = r.MessageDispatcher + } + + if s.Localizer == nil { + s.Localizer = r.Localizer + } + + if s.Client == nil { + s.Client = r.Client + } + + r.sub = append(r.sub, s) + } +} + +func (r *Router) OnMessage(h MessageHandler, filters ...Filter) { + r.routes = append(r.routes, &route{ + msgHandler: h, + filters: filters, + }) +} + +func (r *Router) OnCallback(h CallbackHandler, filters ...Filter) { + r.routes = append(r.routes, &route{ + cbHandler: h, + filters: filters, + }) +} diff --git a/yatgbot/router/schema.go b/yatgbot/router/schema.go new file mode 100644 index 0000000..307af1c --- /dev/null +++ b/yatgbot/router/schema.go @@ -0,0 +1,22 @@ +package router + +import ( + "github.com/YaCodeDev/GoYaCodeDevUtils/yalogger" + "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/fsm" + "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/messagequeue" + "github.com/gotd/td/telegram/message" + "github.com/gotd/td/tg" +) + +type HandlerData struct { + Entities tg.Entities + Sender *message.Sender + Client *tg.Client + Update any + UserID int64 + Peer tg.InputPeerClass + State *fsm.UserFSMStorage + Log yalogger.Logger + Dispatcher *messagequeue.Dispatcher + T func(string) string // localizer +} diff --git a/yatgbot/router/updates.go b/yatgbot/router/updates.go new file mode 100644 index 0000000..be456d7 --- /dev/null +++ b/yatgbot/router/updates.go @@ -0,0 +1,47 @@ +package router + +import ( + "context" + + "github.com/gotd/td/tg" +) + +func (r *Router) Bind(d *tg.UpdateDispatcher) { + d.OnNewMessage(r.wrapMessage) + d.OnBotCallbackQuery(r.wrapCallback) +} + +func (r *Router) wrapMessage(ctx context.Context, ent tg.Entities, upd *tg.UpdateNewMessage) error { + msg, ok := upd.Message.(*tg.Message) + if !ok { + return nil + } + + uid, _ := getUserID(msg.PeerID, msg.FromID) + + chatID, _ := getChatID(msg.PeerID, ent) + + peer, _ := makeInputPeer(msg.PeerID, ent) + + return r.dispatch(ctx, DispatcherDependecies{ + userID: uid, + chatID: chatID, + ent: ent, + update: msg, + inputPeer: peer, + }) +} + +func (r *Router) wrapCallback(ctx context.Context, ent tg.Entities, q *tg.UpdateBotCallbackQuery) error { + chatID, _ := getChatID(q.Peer, ent) + + peer, _ := makeInputPeer(q.Peer, ent) + + return r.dispatch(ctx, DispatcherDependecies{ + userID: q.UserID, + chatID: chatID, + ent: ent, + update: q, + inputPeer: peer, + }) +} From ab9fdec105248014a853a18b53a0526ac461a109 Mon Sep 17 00:00:00 2001 From: Olderestin Date: Wed, 24 Sep 2025 12:16:07 +0300 Subject: [PATCH 04/18] refactor(yatgbot): Partial refactor --- yatgbot/localizer/localizer.go | 2 +- yatgbot/messagequeue/heap.go | 6 ++++-- yatgbot/messagequeue/queue.go | 30 ++++++++++++++---------------- yatgbot/router/dispatcher.go | 8 ++++---- yatgbot/router/filter.go | 2 +- yatgbot/router/middlewares.go | 9 +++++---- yatgbot/router/router.go | 9 +-------- 7 files changed, 30 insertions(+), 36 deletions(-) diff --git a/yatgbot/localizer/localizer.go b/yatgbot/localizer/localizer.go index b9e2ee8..94aa7f6 100644 --- a/yatgbot/localizer/localizer.go +++ b/yatgbot/localizer/localizer.go @@ -47,7 +47,7 @@ func NewLocalizer(fsys fs.FS, defaultLang string) (*Localizer, yaerrors.Error) { return nil }) if err != nil { - return nil, yaerrors.FromError(0, err, "failed to walk directory") + return nil, yaerrors.FromError(500, err, "failed to walk directory") } return loc, nil diff --git a/yatgbot/messagequeue/heap.go b/yatgbot/messagequeue/heap.go index 43dbfad..ee96773 100644 --- a/yatgbot/messagequeue/heap.go +++ b/yatgbot/messagequeue/heap.go @@ -20,14 +20,16 @@ type messageHeap []MessageJob func (h messageHeap) Len() int { return len(h) } -func (h messageHeap) Less(i, j int) bool { +func (h messageHeap) Less(i int, j int) bool { if h[i].Priority == h[j].Priority { return h[i].Timestamp.Before(h[j].Timestamp) } return h[i].Priority < h[j].Priority } -func (h messageHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } + +func (h messageHeap) Swap(i int, j int) { h[i], h[j] = h[j], h[i] } + func (h *messageHeap) Push(x any) { job, ok := x.(MessageJob) if !ok { diff --git a/yatgbot/messagequeue/queue.go b/yatgbot/messagequeue/queue.go index 7cd6040..b8514d7 100644 --- a/yatgbot/messagequeue/queue.go +++ b/yatgbot/messagequeue/queue.go @@ -10,25 +10,28 @@ import ( ) type Dispatcher struct { - mu sync.Mutex + mu sync.Mutex // maybe delete inputChannel chan MessageJob priorityQueueChannel chan MessageJob - wg sync.WaitGroup log yalogger.Logger } -func NewDispatcher(ctx context.Context, workerCount int, log yalogger.Logger) *Dispatcher { +func NewDispatcher( + ctx context.Context, + workerCount uint, + inputChannelCap uint, + priorityChannelCap uint, + log yalogger.Logger, +) *Dispatcher { dispatcher := &Dispatcher{ - inputChannel: make(chan MessageJob, 100), - priorityQueueChannel: make(chan MessageJob, 100), + inputChannel: make(chan MessageJob, inputChannelCap), + priorityQueueChannel: make(chan MessageJob, priorityChannelCap), log: log, } - dispatcher.wg.Add(1) go dispatcher.reorderMessages(ctx) - for i := 0; i < workerCount; i++ { - dispatcher.wg.Add(1) + for i := uint(0); i < workerCount; i++ { go dispatcher.worker(ctx, i) } @@ -36,7 +39,6 @@ func NewDispatcher(ctx context.Context, workerCount int, log yalogger.Logger) *D } func (d *Dispatcher) reorderMessages(ctx context.Context) { - defer d.wg.Done() var pq messageHeap @@ -57,9 +59,7 @@ func (d *Dispatcher) reorderMessages(ctx context.Context) { } } -func (d *Dispatcher) worker(ctx context.Context, id int) { - defer d.wg.Done() - +func (d *Dispatcher) worker(ctx context.Context, id uint) { for { select { case job := <-d.priorityQueueChannel: @@ -75,10 +75,8 @@ func (d *Dispatcher) worker(ctx context.Context, id int) { } } - elapsed := time.Since(start) - if wait := time.Second - elapsed; wait > 0 { - time.Sleep(wait) - } + time.Sleep(time.Second - time.Since(start)) + case <-ctx.Done(): return } diff --git a/yatgbot/router/dispatcher.go b/yatgbot/router/dispatcher.go index 84e40de..9772f50 100644 --- a/yatgbot/router/dispatcher.go +++ b/yatgbot/router/dispatcher.go @@ -23,7 +23,7 @@ func (r *Router) dispatch(ctx context.Context, deps DispatcherDependecies) yaerr strconv.FormatInt(deps.chatID, 10), ) - r.Log.Debugf("Coming update: %+v\nEntities: %+v", deps.update, deps.ent) + r.Log.Debugf("Processing update: %+v\nEntities: %+v", deps.update, deps.ent) for _, rt := range r.routes { ok, err := r.checkFilters( @@ -86,21 +86,21 @@ func (r *Router) checkFilters(ctx context.Context, deps FilterDependecies, local if r.parent != nil { ok, err := r.parent.checkFilters(ctx, deps, nil) if err != nil || !ok { - return ok, err + return ok, err.Wrap("parent filter check failed") } } for _, f := range r.base { ok, err := f(ctx, deps) if err != nil || !ok { - return ok, err + return ok, err.Wrap("base filter check failed") } } for _, f := range local { ok, err := f(ctx, deps) if err != nil || !ok { - return ok, err + return ok, err.Wrap("local filter check failed") } } diff --git a/yatgbot/router/filter.go b/yatgbot/router/filter.go index 665f05f..a9ac6b5 100644 --- a/yatgbot/router/filter.go +++ b/yatgbot/router/filter.go @@ -29,7 +29,7 @@ func StateIs(want ...string) Filter { return func(ctx context.Context, deps FilterDependecies) (bool, yaerrors.Error) { state, _, err := deps.storage.GetState(ctx) if err != nil { - return false, yaerrors.FromError(0, err, "failed to get state for user %d") + return false, yaerrors.FromError(500, err, "failed to get state for user %d") } _, ok := wanted[state] diff --git a/yatgbot/router/middlewares.go b/yatgbot/router/middlewares.go index fecfd68..e526602 100644 --- a/yatgbot/router/middlewares.go +++ b/yatgbot/router/middlewares.go @@ -15,11 +15,12 @@ func (r *Router) AddMiddleware(mw ...HandlerMiddleware) { } func chainMiddleware(final HandlerNext, middlewares ...HandlerMiddleware) HandlerNext { - for i := len(middlewares) - 1; i >= 0; i-- { - middleware := middlewares[i] + for _, mw := range middlewares { + middleware := mw next := final - final = func(ctx context.Context, handlerData *HandlerData, upd any) yaerrors.Error { - return middleware(ctx, handlerData, upd, next) + + final = func(ctx context.Context, hd *HandlerData, upd any) yaerrors.Error { + return middleware(ctx, hd, upd, next) } } diff --git a/yatgbot/router/router.go b/yatgbot/router/router.go index 2c86b3a..4320635 100644 --- a/yatgbot/router/router.go +++ b/yatgbot/router/router.go @@ -45,14 +45,7 @@ type Dependencies struct { func New(name string, deps *Dependencies) *Router { if deps == nil { - deps = &Dependencies{ - FSMStore: nil, - Log: nil, - MessageDispatcher: nil, - Localizer: nil, - Client: nil, - Sender: nil, - } + deps = &Dependencies{} } r := &Router{ From b1fa78fa1413e4e932037c4f90da005f3fde6c2b Mon Sep 17 00:00:00 2001 From: Olderestin Date: Thu, 25 Sep 2025 20:36:16 +0300 Subject: [PATCH 05/18] refactor(yatgbot): Separate yafsm & improve API --- yafsm/consts.go | 6 + yatgbot/fsm/userfsm.go => yafsm/entityfsm.go | 18 +- {yatgbot/fsm => yafsm}/fsm.go | 69 ++++---- yatgbot/localizer/localizer.go | 7 +- yatgbot/messagequeue/consts.go | 6 + yatgbot/messagequeue/heap.go | 165 ++++++++++++++++--- yatgbot/messagequeue/queue.go | 129 +++++++++++---- yatgbot/router/dispatcher.go | 42 ++--- yatgbot/router/filter.go | 33 ++-- yatgbot/router/middlewares.go | 20 ++- yatgbot/router/router.go | 6 +- yatgbot/router/schema.go | 22 +-- yatgbot/router/updates.go | 12 +- yatgbot/router/utils.go | 18 ++ 14 files changed, 391 insertions(+), 162 deletions(-) create mode 100644 yafsm/consts.go rename yatgbot/fsm/userfsm.go => yafsm/entityfsm.go (61%) rename {yatgbot/fsm => yafsm}/fsm.go (64%) create mode 100644 yatgbot/messagequeue/consts.go create mode 100644 yatgbot/router/utils.go diff --git a/yafsm/consts.go b/yafsm/consts.go new file mode 100644 index 0000000..3c730cb --- /dev/null +++ b/yafsm/consts.go @@ -0,0 +1,6 @@ +package yafsm + +const ( + stateKey = "state" + stateDataKey = "state_data" +) diff --git a/yatgbot/fsm/userfsm.go b/yafsm/entityfsm.go similarity index 61% rename from yatgbot/fsm/userfsm.go rename to yafsm/entityfsm.go index 47be5f7..dc11c67 100644 --- a/yatgbot/fsm/userfsm.go +++ b/yafsm/entityfsm.go @@ -1,4 +1,4 @@ -package fsm +package yafsm import ( "context" @@ -6,7 +6,7 @@ import ( "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" ) -type UserFSMStorage struct { +type EntityFSMStorage struct { storage FSM uid string } @@ -14,28 +14,28 @@ type UserFSMStorage struct { func NewUserFSMStorage( storage FSM, uid string, -) *UserFSMStorage { - return &UserFSMStorage{ +) *EntityFSMStorage { + return &EntityFSMStorage{ storage: storage, uid: uid, } } -func (b *UserFSMStorage) SetState( +func (b *EntityFSMStorage) SetState( ctx context.Context, stateData State, ) yaerrors.Error { return b.storage.SetState(ctx, b.uid, stateData) } -func (b *UserFSMStorage) GetState( +func (b *EntityFSMStorage) GetState( ctx context.Context, -) (string, stateDataMarshalled, yaerrors.Error) { +) (string, StateDataMarshalled, yaerrors.Error) { return b.storage.GetState(ctx, b.uid) } -func (b *UserFSMStorage) GetStateData( - stateData stateDataMarshalled, +func (b *EntityFSMStorage) GetStateData( + stateData StateDataMarshalled, emptyState State, ) yaerrors.Error { return b.storage.GetStateData(stateData, emptyState) diff --git a/yatgbot/fsm/fsm.go b/yafsm/fsm.go similarity index 64% rename from yatgbot/fsm/fsm.go rename to yafsm/fsm.go index 4f3a074..aeb768a 100644 --- a/yatgbot/fsm/fsm.go +++ b/yafsm/fsm.go @@ -1,14 +1,13 @@ -package fsm +package yafsm import ( "context" "encoding/json" - "fmt" + "net/http" "reflect" "github.com/YaCodeDev/GoYaCodeDevUtils/yacache" "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" - "github.com/redis/go-redis/v9" ) type State interface { @@ -19,6 +18,7 @@ type BaseState[T State] struct{} func (BaseState[T]) StateName() string { var zero T + t := reflect.TypeOf(zero) if t.Kind() == reflect.Pointer { t = t.Elem() @@ -31,44 +31,43 @@ type EmptyState struct { BaseState[EmptyState] } -type stateDataMarshalled string +type StateDataMarshalled string type StateAndData struct { State string `json:"state"` - StateData string `json:"state_data"` + StateData string `json:"stateData"` } 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 + GetState(ctx context.Context, uid string) (string, StateDataMarshalled, yaerrors.Error) + GetStateData(stateData StateDataMarshalled, emptyState State) yaerrors.Error } -type DefaultFSMStorage struct { - storage yacache.Cache[*redis.Client] +type DefaultFSMStorage[T yacache.Container] struct { + storage yacache.Cache[T] defaultState State } -func NewDefaultFSMStorage( - storage yacache.Cache[*redis.Client], +func NewDefaultFSMStorage[T yacache.Container]( + storage yacache.Cache[T], defaultState State, -) *DefaultFSMStorage { - return &DefaultFSMStorage{ +) *DefaultFSMStorage[T] { + return &DefaultFSMStorage[T]{ storage: storage, defaultState: defaultState, } } -func (b *DefaultFSMStorage) SetState( +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( - 500, + http.StatusInternalServerError, err, "failed to marshal state data", ) @@ -78,24 +77,21 @@ func (b *DefaultFSMStorage) SetState( State: stateData.StateName(), StateData: string(val), }) - if err != nil { return yaerrors.FromError( - 500, + http.StatusInternalServerError, err, "failed to marshal state data", ) } - b.storage.Set(ctx, uid, string(val), 0) - - return nil + return b.storage.Set(ctx, uid, string(val), 0) } -func (b *DefaultFSMStorage) GetState( +func (b *DefaultFSMStorage[T]) GetState( ctx context.Context, uid string, -) (string, stateDataMarshalled, yaerrors.Error) { +) (string, StateDataMarshalled, yaerrors.Error) { data, err := b.storage.Get(ctx, uid) if err != nil { return b.defaultState.StateName(), "", nil @@ -105,26 +101,26 @@ func (b *DefaultFSMStorage) GetState( if err := json.Unmarshal([]byte(data), &stateAndData); err != nil { return "", "", yaerrors.FromError( - 500, + http.StatusInternalServerError, err, "failed to unmarshal state data map", ) } - state, ok := stateAndData["state"] + state, ok := stateAndData[stateKey] if !ok { - return "", "", yaerrors.FromError( - 404, - fmt.Errorf("state not found"), + return "", "", yaerrors.FromString( + http.StatusNotFound, "failed to get state", ) } - return state, stateDataMarshalled(data), nil + + return state, StateDataMarshalled(data), nil } -func (b *DefaultFSMStorage) GetStateData( - stateData stateDataMarshalled, +func (b *DefaultFSMStorage[T]) GetStateData( + stateData StateDataMarshalled, emptyState State, ) yaerrors.Error { if stateData == "" { @@ -135,25 +131,24 @@ func (b *DefaultFSMStorage) GetStateData( if err := json.Unmarshal([]byte(stateData), &stateAndData); err != nil { return yaerrors.FromError( - 500, + http.StatusInternalServerError, err, "failed to unmarshal state data map", ) } - stateDataMarshalled, ok := stateAndData["state_data"] + stateDataMarshalled, ok := stateAndData[stateDataKey] if !ok { - return yaerrors.FromError( - 404, - fmt.Errorf("state data not found"), + return yaerrors.FromString( + http.StatusNotFound, "failed to get state data", ) } if err := json.Unmarshal([]byte(stateDataMarshalled), emptyState); err != nil { return yaerrors.FromError( - 500, + http.StatusInternalServerError, err, "failed to unmarshal state data", ) diff --git a/yatgbot/localizer/localizer.go b/yatgbot/localizer/localizer.go index 94aa7f6..17b77ae 100644 --- a/yatgbot/localizer/localizer.go +++ b/yatgbot/localizer/localizer.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io/fs" + "net/http" "path/filepath" "strings" @@ -47,7 +48,11 @@ func NewLocalizer(fsys fs.FS, defaultLang string) (*Localizer, yaerrors.Error) { return nil }) if err != nil { - return nil, yaerrors.FromError(500, err, "failed to walk directory") + return nil, yaerrors.FromError( + http.StatusInternalServerError, + err, + "failed to walk directory", + ) } return loc, nil diff --git a/yatgbot/messagequeue/consts.go b/yatgbot/messagequeue/consts.go new file mode 100644 index 0000000..b9e0f0c --- /dev/null +++ b/yatgbot/messagequeue/consts.go @@ -0,0 +1,6 @@ +package messagequeue + +const ( + HighPriorityQueueSize = 1024 + SingleMessage = 1 +) diff --git a/yatgbot/messagequeue/heap.go b/yatgbot/messagequeue/heap.go index ee96773..251226d 100644 --- a/yatgbot/messagequeue/heap.go +++ b/yatgbot/messagequeue/heap.go @@ -1,49 +1,164 @@ package messagequeue import ( + "cmp" + "context" + "fmt" + "net/http" + "slices" + "sync" "time" - "github.com/gotd/td/telegram/message" + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" + "github.com/gotd/td/bin" "github.com/gotd/td/tg" ) type MessageJob struct { - Priority uint16 - Timestamp time.Time - Text string - Markup tg.ReplyMarkupClass - Sender *message.Sender - To tg.InputPeerClass + ID uint64 + Priority uint16 + Request bin.Encoder + ResultCh chan JobResult + Timestamp time.Time + IsPlaceholder bool + TaskCount uint } -type messageHeap []MessageJob +type JobResult struct { + Updates tg.UpdatesClass + Err yaerrors.Error +} + +func (j MessageJob) Execute( + ctx context.Context, + dispatcher *Dispatcher, + workerID uint, +) JobResult { + if j.IsPlaceholder { + return JobResult{} + } + + if j.TaskCount > 1 { + dispatcher.AddEmptyJob(j.TaskCount - 1) + } + + var result tg.UpdatesBox + + err := dispatcher.Client.Invoke(ctx, j.Request, &result) + + return JobResult{ + Updates: result.Updates, + Err: yaerrors.FromError( + http.StatusInternalServerError, + err, + fmt.Sprintf("worker %d: failed to send message", workerID), + ), + } +} + +type messageHeap struct { + jobs []MessageJob + mu sync.Mutex +} + +func newMessageHeap() messageHeap { + return messageHeap{ + jobs: make([]MessageJob, 0, HighPriorityQueueSize), + } +} + +func (h *messageHeap) sort() { + slices.SortFunc(h.jobs, func(a, b MessageJob) int { + if a.IsPlaceholder && b.IsPlaceholder { + return 0 + } + + if a.IsPlaceholder { + return 1 + } + + if a.Priority != b.Priority { + return cmp.Compare(b.Priority, a.Priority) + } + + switch { + case a.Timestamp.Before(b.Timestamp): + return 1 + case a.Timestamp.After(b.Timestamp): + return -1 + default: + return 0 + } + }) +} + +func (h *messageHeap) Push(job MessageJob) { + h.mu.Lock() + + h.jobs = append(h.jobs, job) + h.sort() + + h.mu.Unlock() +} -func (h messageHeap) Len() int { return len(h) } +func (h *messageHeap) Len() int { + h.mu.Lock() + defer h.mu.Unlock() -func (h messageHeap) Less(i int, j int) bool { - if h[i].Priority == h[j].Priority { - return h[i].Timestamp.Before(h[j].Timestamp) + return len(h.jobs) +} + +func (h *messageHeap) Pop() (MessageJob, bool) { + if h.Len() == 0 { + return MessageJob{}, false } - return h[i].Priority < h[j].Priority + h.mu.Lock() + + last := len(h.jobs) - 1 + job := h.jobs[last] + h.jobs = h.jobs[:last] + + h.mu.Unlock() + + return job, true } -func (h messageHeap) Swap(i int, j int) { h[i], h[j] = h[j], h[i] } +func (h *messageHeap) Delete(id uint64) bool { + h.mu.Lock() + defer h.mu.Unlock() -func (h *messageHeap) Push(x any) { - job, ok := x.(MessageJob) - if !ok { - return + for i, job := range h.jobs { + if job.ID == id { + h.jobs = append(h.jobs[:i], h.jobs[i+1:]...) + + return true + } } - *h = append(*h, job) + return false } -func (h *messageHeap) Pop() any { - old := *h - n := len(old) - x := old[n-1] - *h = old[:n-1] +func (h *messageHeap) DeleteFunc(deleteFunc func(MessageJob) bool) []uint64 { + var deletedEntries []uint64 + + h.mu.Lock() + + newJobs := make([]MessageJob, 0, len(h.jobs)) + + for _, job := range h.jobs { + if deleteFunc(job) { + deletedEntries = append(deletedEntries, job.ID) + + continue + } + + newJobs = append(newJobs, job) + } + + h.jobs = newJobs + + h.mu.Unlock() - return x + return deletedEntries } diff --git a/yatgbot/messagequeue/queue.go b/yatgbot/messagequeue/queue.go index b8514d7..5ac27f5 100644 --- a/yatgbot/messagequeue/queue.go +++ b/yatgbot/messagequeue/queue.go @@ -1,61 +1,59 @@ package messagequeue import ( - "container/heap" "context" + "math/rand" "sync" "time" "github.com/YaCodeDev/GoYaCodeDevUtils/yalogger" + "github.com/YaCodeDev/GoYaCodeDevUtils/yatgclient" + "github.com/gotd/td/bin" + "github.com/gotd/td/tg" ) type Dispatcher struct { - mu sync.Mutex // maybe delete - inputChannel chan MessageJob + Client yatgclient.Client priorityQueueChannel chan MessageJob + heap messageHeap + cond sync.Cond log yalogger.Logger } func NewDispatcher( ctx context.Context, workerCount uint, - inputChannelCap uint, - priorityChannelCap uint, log yalogger.Logger, ) *Dispatcher { dispatcher := &Dispatcher{ - inputChannel: make(chan MessageJob, inputChannelCap), - priorityQueueChannel: make(chan MessageJob, priorityChannelCap), + priorityQueueChannel: make(chan MessageJob), log: log, + heap: newMessageHeap(), } - go dispatcher.reorderMessages(ctx) + go dispatcher.proccessMessagesQueue() - for i := uint(0); i < workerCount; i++ { + for i := range workerCount { go dispatcher.worker(ctx, i) } return dispatcher } -func (d *Dispatcher) reorderMessages(ctx context.Context) { - - var pq messageHeap - - heap.Init(&pq) - +func (d *Dispatcher) proccessMessagesQueue() { for { - select { - case job := <-d.inputChannel: - heap.Push(&pq, job) - case <-ctx.Done(): - return + if d.heap.Len() == 0 { + d.cond.L.Lock() + d.cond.Wait() + d.cond.L.Unlock() } - if pq.Len() > 0 { - job := heap.Pop(&pq).(MessageJob) - d.priorityQueueChannel <- job + job, ok := d.heap.Pop() + if !ok { + continue } + + d.priorityQueueChannel <- job } } @@ -65,14 +63,12 @@ func (d *Dispatcher) worker(ctx context.Context, id uint) { case job := <-d.priorityQueueChannel: start := time.Now() - if job.Markup == nil { - if _, err := job.Sender.To(job.To).Text(ctx, job.Text); err != nil { - d.log.Infof("[worker %d] error sending message without markup: %v", id, err) - } - } else { - if _, err := job.Sender.To(job.To).Markup(job.Markup).Text(ctx, job.Text); err != nil { - d.log.Infof("[worker %d] error sending message with markup: %v", id, err) - } + err := job.Execute(ctx, d, id) + + select { + case job.ResultCh <- err: + case <-ctx.Done(): + return } time.Sleep(time.Second - time.Since(start)) @@ -83,6 +79,73 @@ func (d *Dispatcher) worker(ctx context.Context, id uint) { } } -func (d *Dispatcher) Add(job MessageJob) { - d.inputChannel <- job +func (d *Dispatcher) DeleteJob(id uint64) bool { + return d.heap.Delete(id) +} + +func (d *Dispatcher) DeleteJobFunc(deleteFunc func(MessageJob) bool) []uint64 { + return d.heap.DeleteFunc(deleteFunc) +} + +func (d *Dispatcher) AddRawJob( + request bin.Encoder, + priority uint16, + taskCount uint, +) (uint64, <-chan JobResult) { + job := MessageJob{ + ID: rand.Uint64(), + Priority: priority, + Request: request, + ResultCh: make(chan JobResult, 1), + Timestamp: time.Now(), + TaskCount: taskCount, + } + + d.heap.Push(job) + + d.cond.Signal() + + return job.ID, job.ResultCh +} + +func (d *Dispatcher) AddEmptyJob(count uint) { + for range count { + d.heap.Push(MessageJob{ + IsPlaceholder: true, + }) + } +} + +func (d *Dispatcher) AddMessagesForward( + req *tg.MessagesForwardMessagesRequest, + priority uint16, +) (uint64, <-chan JobResult) { + req.RandomID = make([]int64, len(req.ID)) + for i := range req.RandomID { + req.RandomID[i] = rand.Int63() + } + + return d.AddRawJob(req, priority, uint(len(req.RandomID))) +} + +func (d *Dispatcher) SendMessage( + req *tg.MessagesSendMessageRequest, + priority uint16, +) (uint64, <-chan JobResult) { + if req.RandomID == 0 { + req.RandomID = rand.Int63() + } + + return d.AddRawJob(req, priority, SingleMessage) +} + +func (d *Dispatcher) SendMultiMedia( + req *tg.MessagesSendMultiMediaRequest, + priority uint16, +) (uint64, <-chan JobResult) { + for i := range req.MultiMedia { + req.MultiMedia[i].RandomID = rand.Int63() + } + + return d.AddRawJob(req, priority, uint(len(req.MultiMedia))) } diff --git a/yatgbot/router/dispatcher.go b/yatgbot/router/dispatcher.go index 9772f50..e7e8e12 100644 --- a/yatgbot/router/dispatcher.go +++ b/yatgbot/router/dispatcher.go @@ -5,30 +5,30 @@ import ( "strconv" "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" - "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/fsm" + "github.com/YaCodeDev/GoYaCodeDevUtils/yafsm" "github.com/gotd/td/tg" ) -type DispatcherDependecies struct { +type DispatcherDependencies struct { userID int64 chatID int64 ent tg.Entities - update any + update tg.UpdateClass inputPeer tg.InputPeerClass } -func (r *Router) dispatch(ctx context.Context, deps DispatcherDependecies) yaerrors.Error { - userFSMStorage := fsm.NewUserFSMStorage( +func (r *Router) dispatch(ctx context.Context, deps DispatcherDependencies) yaerrors.Error { + userFSMStorage := yafsm.NewUserFSMStorage( r.FSMStore, strconv.FormatInt(deps.chatID, 10), ) - r.Log.Debugf("Processing update: %+v\nEntities: %+v", deps.update, deps.ent) + r.Log.Debugf("Processing update: %+v with entities: %+v", deps.update, deps.ent) for _, rt := range r.routes { ok, err := r.checkFilters( ctx, - FilterDependecies{ + FilterDependencies{ update: deps.update, storage: *userFSMStorage, userID: deps.userID, @@ -52,20 +52,20 @@ func (r *Router) dispatch(ctx context.Context, deps DispatcherDependecies) yaerr } hdata := &HandlerData{ - Entities: deps.ent, - Sender: r.Sender, - Update: deps.update, - UserID: deps.userID, - Peer: deps.inputPeer, - State: userFSMStorage, - Log: r.Log, - Dispatcher: r.MessageDispatcher, - T: lang, - Client: r.Client, + Entities: deps.ent, + Sender: r.Sender, + Update: deps.update, + UserID: deps.userID, + Peer: deps.inputPeer, + StateStorage: userFSMStorage, + Log: r.Log, + Dispatcher: r.MessageDispatcher, + T: lang, + Client: r.Client, } switch u := deps.update.(type) { - case *tg.Message: + case *tg.UpdateNewMessage: return chainMiddleware(wrapHandler(rt.msgHandler), r.collectMiddlewares()...)(ctx, hdata, u) case *tg.UpdateBotCallbackQuery: return chainMiddleware(wrapHandler(rt.cbHandler), r.collectMiddlewares()...)(ctx, hdata, u) @@ -82,7 +82,11 @@ func (r *Router) dispatch(ctx context.Context, deps DispatcherDependecies) yaerr return nil } -func (r *Router) checkFilters(ctx context.Context, deps FilterDependecies, local []Filter) (bool, yaerrors.Error) { +func (r *Router) checkFilters( + ctx context.Context, + deps FilterDependencies, + local []Filter, +) (bool, yaerrors.Error) { if r.parent != nil { ok, err := r.parent.checkFilters(ctx, deps, nil) if err != nil || !ok { diff --git a/yatgbot/router/filter.go b/yatgbot/router/filter.go index a9ac6b5..9eff6fc 100644 --- a/yatgbot/router/filter.go +++ b/yatgbot/router/filter.go @@ -2,21 +2,22 @@ package router import ( "context" + "net/http" "regexp" "strings" "github.com/gotd/td/tg" "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" - "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/fsm" + "github.com/YaCodeDev/GoYaCodeDevUtils/yafsm" ) -type Filter func(ctx context.Context, deps FilterDependecies) (bool, yaerrors.Error) +type Filter func(ctx context.Context, deps FilterDependencies) (bool, yaerrors.Error) -type FilterDependecies struct { - storage fsm.UserFSMStorage +type FilterDependencies struct { + storage yafsm.EntityFSMStorage userID int64 - update any + update tg.UpdateClass } func StateIs(want ...string) Filter { @@ -26,10 +27,13 @@ func StateIs(want ...string) Filter { wanted[s] = struct{}{} } - return func(ctx context.Context, deps FilterDependecies) (bool, yaerrors.Error) { + return func(ctx context.Context, deps FilterDependencies) (bool, yaerrors.Error) { state, _, err := deps.storage.GetState(ctx) if err != nil { - return false, yaerrors.FromError(500, err, "failed to get state for user %d") + return false, yaerrors.FromError( + http.StatusInternalServerError, + err, "failed to get state for user %d", + ) } _, ok := wanted[state] @@ -39,8 +43,8 @@ func StateIs(want ...string) Filter { } func TextEq(want string) Filter { - return func(_ context.Context, deps FilterDependecies) (bool, yaerrors.Error) { - if m, ok := deps.update.(*tg.Message); ok && m.Message == want { + return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { + if m, ok := extractMessageFromUpdate(deps.update); ok && m.Message == want { return true, nil } @@ -49,8 +53,8 @@ func TextEq(want string) Filter { } func TextRegex(re *regexp.Regexp) Filter { - return func(_ context.Context, deps FilterDependecies) (bool, yaerrors.Error) { - if m, ok := deps.update.(*tg.Message); ok && re.MatchString(m.Message) { + return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { + if m, ok := extractMessageFromUpdate(deps.update); ok && re.MatchString(m.Message) { return true, nil } @@ -59,7 +63,7 @@ func TextRegex(re *regexp.Regexp) Filter { } func CallbackEq(data string) Filter { - return func(_ context.Context, deps FilterDependecies) (bool, yaerrors.Error) { + return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { if q, ok := deps.update.(*tg.UpdateBotCallbackQuery); ok && string(q.Data) == data { return true, nil } @@ -69,8 +73,9 @@ func CallbackEq(data string) Filter { } func CallbackPrefix(prefix string) Filter { - return func(_ context.Context, deps FilterDependecies) (bool, yaerrors.Error) { - if q, ok := deps.update.(*tg.UpdateBotCallbackQuery); ok && strings.HasPrefix(string(q.Data), prefix) { + return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { + if q, ok := deps.update.(*tg.UpdateBotCallbackQuery); ok && + strings.HasPrefix(string(q.Data), prefix) { return true, nil } diff --git a/yatgbot/router/middlewares.go b/yatgbot/router/middlewares.go index e526602..e9ebb3b 100644 --- a/yatgbot/router/middlewares.go +++ b/yatgbot/router/middlewares.go @@ -4,11 +4,17 @@ import ( "context" "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" + "github.com/gotd/td/tg" ) -type HandlerNext func(ctx context.Context, handlerData *HandlerData, upd any) yaerrors.Error +type HandlerNext func(ctx context.Context, handlerData *HandlerData, upd tg.UpdateClass) yaerrors.Error -type HandlerMiddleware func(ctx context.Context, handlerData *HandlerData, upd any, next HandlerNext) yaerrors.Error +type HandlerMiddleware func( + ctx context.Context, + handlerData *HandlerData, + upd tg.UpdateClass, + next HandlerNext, +) yaerrors.Error func (r *Router) AddMiddleware(mw ...HandlerMiddleware) { r.middlewares = append(r.middlewares, mw...) @@ -19,7 +25,7 @@ func chainMiddleware(final HandlerNext, middlewares ...HandlerMiddleware) Handle middleware := mw next := final - final = func(ctx context.Context, hd *HandlerData, upd any) yaerrors.Error { + final = func(ctx context.Context, hd *HandlerData, upd tg.UpdateClass) yaerrors.Error { return middleware(ctx, hd, upd, next) } } @@ -27,9 +33,11 @@ func chainMiddleware(final HandlerNext, middlewares ...HandlerMiddleware) Handle return final } -func wrapHandler[T any](h func(context.Context, *HandlerData, *T) yaerrors.Error) HandlerNext { - return func(ctx context.Context, handlerData *HandlerData, upd any) yaerrors.Error { - if t, ok := upd.(*T); ok { +func wrapHandler[T tg.UpdateClass]( + h func(context.Context, *HandlerData, T) yaerrors.Error, +) HandlerNext { + return func(ctx context.Context, handlerData *HandlerData, upd tg.UpdateClass) yaerrors.Error { + if t, ok := upd.(T); ok { return h(ctx, handlerData, t) } diff --git a/yatgbot/router/router.go b/yatgbot/router/router.go index 4320635..d505b34 100644 --- a/yatgbot/router/router.go +++ b/yatgbot/router/router.go @@ -4,8 +4,8 @@ import ( "context" "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" + "github.com/YaCodeDev/GoYaCodeDevUtils/yafsm" "github.com/YaCodeDev/GoYaCodeDevUtils/yalogger" - "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/fsm" "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/localizer" "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/messagequeue" "github.com/gotd/td/telegram/message" @@ -13,7 +13,7 @@ import ( ) type ( - MessageHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.Message) yaerrors.Error + MessageHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateNewMessage) yaerrors.Error CallbackHandler func(ctx context.Context, handlerData *HandlerData, cb *tg.UpdateBotCallbackQuery) yaerrors.Error ) @@ -35,7 +35,7 @@ type Router struct { } type Dependencies struct { - FSMStore fsm.FSM + FSMStore yafsm.FSM Log yalogger.Logger MessageDispatcher *messagequeue.Dispatcher Localizer *localizer.Localizer diff --git a/yatgbot/router/schema.go b/yatgbot/router/schema.go index 307af1c..0afb431 100644 --- a/yatgbot/router/schema.go +++ b/yatgbot/router/schema.go @@ -1,22 +1,22 @@ package router import ( + "github.com/YaCodeDev/GoYaCodeDevUtils/yafsm" "github.com/YaCodeDev/GoYaCodeDevUtils/yalogger" - "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/fsm" "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/messagequeue" "github.com/gotd/td/telegram/message" "github.com/gotd/td/tg" ) type HandlerData struct { - Entities tg.Entities - Sender *message.Sender - Client *tg.Client - Update any - UserID int64 - Peer tg.InputPeerClass - State *fsm.UserFSMStorage - Log yalogger.Logger - Dispatcher *messagequeue.Dispatcher - T func(string) string // localizer + Entities tg.Entities + Sender *message.Sender + Client *tg.Client + Update tg.UpdateClass + UserID int64 + Peer tg.InputPeerClass + StateStorage *yafsm.EntityFSMStorage + Log yalogger.Logger + Dispatcher *messagequeue.Dispatcher + T func(string) string // localizer } diff --git a/yatgbot/router/updates.go b/yatgbot/router/updates.go index be456d7..9c87ca7 100644 --- a/yatgbot/router/updates.go +++ b/yatgbot/router/updates.go @@ -23,21 +23,25 @@ func (r *Router) wrapMessage(ctx context.Context, ent tg.Entities, upd *tg.Updat peer, _ := makeInputPeer(msg.PeerID, ent) - return r.dispatch(ctx, DispatcherDependecies{ + return r.dispatch(ctx, DispatcherDependencies{ userID: uid, chatID: chatID, ent: ent, - update: msg, + update: upd, inputPeer: peer, }) } -func (r *Router) wrapCallback(ctx context.Context, ent tg.Entities, q *tg.UpdateBotCallbackQuery) error { +func (r *Router) wrapCallback( + ctx context.Context, + ent tg.Entities, + q *tg.UpdateBotCallbackQuery, +) error { chatID, _ := getChatID(q.Peer, ent) peer, _ := makeInputPeer(q.Peer, ent) - return r.dispatch(ctx, DispatcherDependecies{ + return r.dispatch(ctx, DispatcherDependencies{ userID: q.UserID, chatID: chatID, ent: ent, diff --git a/yatgbot/router/utils.go b/yatgbot/router/utils.go new file mode 100644 index 0000000..850598f --- /dev/null +++ b/yatgbot/router/utils.go @@ -0,0 +1,18 @@ +package router + +import "github.com/gotd/td/tg" + +func extractMessageFromUpdate(upd tg.UpdateClass) (*tg.Message, bool) { + switch u := upd.(type) { + case *tg.UpdateNewMessage: + if msg, ok := u.Message.(*tg.Message); ok { + return msg, true + } + case *tg.UpdateNewChannelMessage: + if msg, ok := u.Message.(*tg.Message); ok { + return msg, true + } + } + + return nil, false +} From 883b9952608a93c09518d331e74acb1ac098231d Mon Sep 17 00:00:00 2001 From: Olderestin Date: Thu, 25 Sep 2025 21:18:28 +0300 Subject: [PATCH 06/18] chore(yatgbot): Add docstrings --- yafsm/entityfsm.go | 31 +++++++++++++++++++++++++++++++ yafsm/fsm.go | 12 ++++++++++++ yatgbot/messagequeue/heap.go | 15 +++++++++++++++ yatgbot/messagequeue/queue.go | 13 +++++++++++++ yatgbot/router/dispatcher.go | 7 +++++++ yatgbot/router/filter.go | 7 +++++++ yatgbot/router/middlewares.go | 10 ++++++++++ yatgbot/router/router.go | 28 ++++++++++++++++++++++++++-- yatgbot/router/schema.go | 1 + yatgbot/router/updates.go | 3 +++ yatgbot/router/utils.go | 1 + 11 files changed, 126 insertions(+), 2 deletions(-) diff --git a/yafsm/entityfsm.go b/yafsm/entityfsm.go index dc11c67..f0f7181 100644 --- a/yafsm/entityfsm.go +++ b/yafsm/entityfsm.go @@ -6,11 +6,13 @@ import ( "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. func NewUserFSMStorage( storage FSM, uid string, @@ -21,6 +23,15 @@ func NewUserFSMStorage( } } +// 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, @@ -28,12 +39,32 @@ func (b *EntityFSMStorage) SetState( 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) { 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, diff --git a/yafsm/fsm.go b/yafsm/fsm.go index aeb768a..e13ae5a 100644 --- a/yafsm/fsm.go +++ b/yafsm/fsm.go @@ -10,12 +10,15 @@ import ( "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 @@ -27,28 +30,34 @@ func (BaseState[T]) StateName() string { 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. func NewDefaultFSMStorage[T yacache.Container]( storage yacache.Cache[T], defaultState State, @@ -59,6 +68,7 @@ func NewDefaultFSMStorage[T yacache.Container]( } } +// SetState sets the state for a given user ID. func (b *DefaultFSMStorage[T]) SetState( ctx context.Context, uid string, @@ -88,6 +98,7 @@ func (b *DefaultFSMStorage[T]) SetState( return b.storage.Set(ctx, uid, string(val), 0) } +// GetState retrieves the current state and its marshalled data for a given user ID. func (b *DefaultFSMStorage[T]) GetState( ctx context.Context, uid string, @@ -119,6 +130,7 @@ func (b *DefaultFSMStorage[T]) GetState( return state, StateDataMarshalled(data), nil } +// GetStateData unmarshals the state data into the provided empty state struct. func (b *DefaultFSMStorage[T]) GetStateData( stateData StateDataMarshalled, emptyState State, diff --git a/yatgbot/messagequeue/heap.go b/yatgbot/messagequeue/heap.go index 251226d..5981d6a 100644 --- a/yatgbot/messagequeue/heap.go +++ b/yatgbot/messagequeue/heap.go @@ -14,6 +14,7 @@ import ( "github.com/gotd/td/tg" ) +// MessageJob represents a job to send a message with a certain priority. type MessageJob struct { ID uint64 Priority uint16 @@ -24,11 +25,15 @@ type MessageJob struct { TaskCount uint } +// JobResult represents the result of a message job execution. type JobResult struct { Updates tg.UpdatesClass Err yaerrors.Error } +// Execute performs the message sending operation. +// If the job is a placeholder, it returns an empty result. +// If the job has multiple tasks, it adds empty jobs to the dispatcher. func (j MessageJob) Execute( ctx context.Context, dispatcher *Dispatcher, @@ -56,17 +61,22 @@ func (j MessageJob) Execute( } } +// messageHeap is a thread-safe priority queue for MessageJob. type messageHeap struct { jobs []MessageJob mu sync.Mutex } +// newMessageHeap creates a new instance of messageHeap. func newMessageHeap() messageHeap { return messageHeap{ jobs: make([]MessageJob, 0, HighPriorityQueueSize), } } +// sort sorts the jobs in the heap based on priority and timestamp. +// Placeholders are always sorted to the end. +// Higher priority jobs come first, and for equal priority, older jobs come first. func (h *messageHeap) sort() { slices.SortFunc(h.jobs, func(a, b MessageJob) int { if a.IsPlaceholder && b.IsPlaceholder { @@ -92,6 +102,7 @@ func (h *messageHeap) sort() { }) } +// Push adds a new job to the heap and sorts it. func (h *messageHeap) Push(job MessageJob) { h.mu.Lock() @@ -101,6 +112,7 @@ func (h *messageHeap) Push(job MessageJob) { h.mu.Unlock() } +// Len returns the number of jobs in the heap. func (h *messageHeap) Len() int { h.mu.Lock() defer h.mu.Unlock() @@ -108,6 +120,7 @@ func (h *messageHeap) Len() int { return len(h.jobs) } +// Pop removes and returns the highest priority job from the heap. func (h *messageHeap) Pop() (MessageJob, bool) { if h.Len() == 0 { return MessageJob{}, false @@ -124,6 +137,7 @@ func (h *messageHeap) Pop() (MessageJob, bool) { return job, true } +// Delete removes a job with the specified ID from the heap. func (h *messageHeap) Delete(id uint64) bool { h.mu.Lock() defer h.mu.Unlock() @@ -139,6 +153,7 @@ func (h *messageHeap) Delete(id uint64) bool { return false } +// DeleteFunc removes jobs that satisfy the given condition from the heap. func (h *messageHeap) DeleteFunc(deleteFunc func(MessageJob) bool) []uint64 { var deletedEntries []uint64 diff --git a/yatgbot/messagequeue/queue.go b/yatgbot/messagequeue/queue.go index 5ac27f5..bd6c7ed 100644 --- a/yatgbot/messagequeue/queue.go +++ b/yatgbot/messagequeue/queue.go @@ -12,6 +12,7 @@ import ( "github.com/gotd/td/tg" ) +// Dispatcher handles message sending with priority and concurrency control. type Dispatcher struct { Client yatgclient.Client priorityQueueChannel chan MessageJob @@ -20,6 +21,7 @@ type Dispatcher struct { log yalogger.Logger } +// NewDispatcher creates a new Dispatcher with the given number of workers. func NewDispatcher( ctx context.Context, workerCount uint, @@ -40,6 +42,8 @@ func NewDispatcher( return dispatcher } +// proccessMessagesQueue continuously processes jobs from the heap and sends them to the priority queue channel. +// It waits for new jobs if the heap is empty. func (d *Dispatcher) proccessMessagesQueue() { for { if d.heap.Len() == 0 { @@ -57,6 +61,8 @@ func (d *Dispatcher) proccessMessagesQueue() { } } +// worker processes jobs from the priority queue channel. +// It executes each job and sends the result back through the job's ResultCh. func (d *Dispatcher) worker(ctx context.Context, id uint) { for { select { @@ -79,14 +85,17 @@ func (d *Dispatcher) worker(ctx context.Context, id uint) { } } +// DeleteJob removes a job from the heap by its ID. func (d *Dispatcher) DeleteJob(id uint64) bool { return d.heap.Delete(id) } +// DeleteJobFunc removes jobs from the heap that satisfy the given condition. func (d *Dispatcher) DeleteJobFunc(deleteFunc func(MessageJob) bool) []uint64 { return d.heap.DeleteFunc(deleteFunc) } +// AddRawJob adds a raw job to the dispatcher with the specified request, priority, and task count. func (d *Dispatcher) AddRawJob( request bin.Encoder, priority uint16, @@ -108,6 +117,7 @@ func (d *Dispatcher) AddRawJob( return job.ID, job.ResultCh } +// AddEmptyJob adds the specified number of placeholder jobs to the dispatcher. func (d *Dispatcher) AddEmptyJob(count uint) { for range count { d.heap.Push(MessageJob{ @@ -116,6 +126,7 @@ func (d *Dispatcher) AddEmptyJob(count uint) { } } +// AddMessagesForfard adds a message forwarding job to the dispatcher. func (d *Dispatcher) AddMessagesForward( req *tg.MessagesForwardMessagesRequest, priority uint16, @@ -128,6 +139,7 @@ func (d *Dispatcher) AddMessagesForward( return d.AddRawJob(req, priority, uint(len(req.RandomID))) } +// SendMessage adds a message sending job to the dispatcher. func (d *Dispatcher) SendMessage( req *tg.MessagesSendMessageRequest, priority uint16, @@ -139,6 +151,7 @@ func (d *Dispatcher) SendMessage( return d.AddRawJob(req, priority, SingleMessage) } +// SendMedia adds a media sending job to the dispatcher. func (d *Dispatcher) SendMultiMedia( req *tg.MessagesSendMultiMediaRequest, priority uint16, diff --git a/yatgbot/router/dispatcher.go b/yatgbot/router/dispatcher.go index e7e8e12..9cd9d07 100644 --- a/yatgbot/router/dispatcher.go +++ b/yatgbot/router/dispatcher.go @@ -9,6 +9,7 @@ import ( "github.com/gotd/td/tg" ) +// DispatcherDependencies holds the dependencies required for dispatching an update. type DispatcherDependencies struct { userID int64 chatID int64 @@ -17,6 +18,8 @@ type DispatcherDependencies struct { inputPeer tg.InputPeerClass } +// dispatch processes the update by checking filters and executing the appropriate handler. +// It also supports nested routers by dispatching to sub-routers if no local route matches. func (r *Router) dispatch(ctx context.Context, deps DispatcherDependencies) yaerrors.Error { userFSMStorage := yafsm.NewUserFSMStorage( r.FSMStore, @@ -82,6 +85,7 @@ func (r *Router) dispatch(ctx context.Context, deps DispatcherDependencies) yaer return nil } +// checkFilters checks the filters of the current router and its parents recursively. func (r *Router) checkFilters( ctx context.Context, deps FilterDependencies, @@ -111,6 +115,7 @@ func (r *Router) checkFilters( return true, nil } +// makeInputPeer converts a tg.PeerClass to a tg.InputPeerClass using the provided entities. func makeInputPeer(p tg.PeerClass, ents tg.Entities) (tg.InputPeerClass, bool) { switch v := p.(type) { case *tg.PeerUser: @@ -142,6 +147,7 @@ func makeInputPeer(p tg.PeerClass, ents tg.Entities) (tg.InputPeerClass, bool) { return nil, false } +// getChatID extracts the chat ID from a tg.PeerClass using the provided entities. func getChatID(peer tg.PeerClass, ents tg.Entities) (int64, bool) { switch v := peer.(type) { case *tg.PeerUser: @@ -160,6 +166,7 @@ func getChatID(peer tg.PeerClass, ents tg.Entities) (int64, bool) { } } +// getUserID extracts the user ID from a tg.PeerClass or from the FromID field if available. func getUserID(peer tg.PeerClass, fromID tg.PeerClass) (int64, bool) { switch v := peer.(type) { case *tg.PeerUser: diff --git a/yatgbot/router/filter.go b/yatgbot/router/filter.go index 9eff6fc..abd4c32 100644 --- a/yatgbot/router/filter.go +++ b/yatgbot/router/filter.go @@ -12,14 +12,17 @@ import ( "github.com/YaCodeDev/GoYaCodeDevUtils/yafsm" ) +// Filter is a function that determines whether a given update should be processed type Filter func(ctx context.Context, deps FilterDependencies) (bool, yaerrors.Error) +// FilterDependencies holds the dependencies required by filters type FilterDependencies struct { storage yafsm.EntityFSMStorage userID int64 update tg.UpdateClass } +// StateIs creates a filter that checks if the user's state matches any of the provided states. func StateIs(want ...string) Filter { wanted := make(map[string]struct{}, len(want)) @@ -42,6 +45,7 @@ func StateIs(want ...string) Filter { } } +// TextEq creates a filter that checks if the message text equals the specified string. func TextEq(want string) Filter { return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { if m, ok := extractMessageFromUpdate(deps.update); ok && m.Message == want { @@ -52,6 +56,7 @@ func TextEq(want string) Filter { } } +// TextRegex creates a filter that checks if the message text matches the specified regex. func TextRegex(re *regexp.Regexp) Filter { return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { if m, ok := extractMessageFromUpdate(deps.update); ok && re.MatchString(m.Message) { @@ -62,6 +67,7 @@ func TextRegex(re *regexp.Regexp) Filter { } } +// CallbackEq creates a filter that checks if the callback query data equals the specified string. func CallbackEq(data string) Filter { return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { if q, ok := deps.update.(*tg.UpdateBotCallbackQuery); ok && string(q.Data) == data { @@ -72,6 +78,7 @@ func CallbackEq(data string) Filter { } } +// CallbackPrefix creates a filter that checks if the callback query data starts with the specified prefix. func CallbackPrefix(prefix string) Filter { return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { if q, ok := deps.update.(*tg.UpdateBotCallbackQuery); ok && diff --git a/yatgbot/router/middlewares.go b/yatgbot/router/middlewares.go index e9ebb3b..852fc0f 100644 --- a/yatgbot/router/middlewares.go +++ b/yatgbot/router/middlewares.go @@ -7,8 +7,10 @@ import ( "github.com/gotd/td/tg" ) +// HandlerNext is a function that represents the next handler in the middleware chain. type HandlerNext func(ctx context.Context, handlerData *HandlerData, upd tg.UpdateClass) yaerrors.Error +// HandlerMiddleware is a middleware function that can process an update before or after the main handler. type HandlerMiddleware func( ctx context.Context, handlerData *HandlerData, @@ -16,10 +18,16 @@ type HandlerMiddleware func( next HandlerNext, ) yaerrors.Error +// AddMiddleware adds one or more middlewares to the router. +// +// Example of usage: +// +// r.AddMiddleware(loggingMiddleware, authMiddleware) func (r *Router) AddMiddleware(mw ...HandlerMiddleware) { r.middlewares = append(r.middlewares, mw...) } +// chainMiddleware chains the provided middlewares and returns a single HandlerNext function. func chainMiddleware(final HandlerNext, middlewares ...HandlerMiddleware) HandlerNext { for _, mw := range middlewares { middleware := mw @@ -33,6 +41,7 @@ func chainMiddleware(final HandlerNext, middlewares ...HandlerMiddleware) Handle return final } +// wrapHandler wraps a specific handler function to match the HandlerNext signature. func wrapHandler[T tg.UpdateClass]( h func(context.Context, *HandlerData, T) yaerrors.Error, ) HandlerNext { @@ -45,6 +54,7 @@ func wrapHandler[T tg.UpdateClass]( } } +// collectMiddlewares collects middlewares from the current router and its parent routers. func (r *Router) collectMiddlewares() []HandlerMiddleware { if r.parent == nil { return r.middlewares diff --git a/yatgbot/router/router.go b/yatgbot/router/router.go index d505b34..4114ae8 100644 --- a/yatgbot/router/router.go +++ b/yatgbot/router/router.go @@ -12,17 +12,21 @@ import ( "github.com/gotd/td/tg" ) +// MessageHandler is a function that processes incoming messages. +// CallbackHandler is a function that processes incoming callback queries. type ( MessageHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateNewMessage) yaerrors.Error CallbackHandler func(ctx context.Context, handlerData *HandlerData, cb *tg.UpdateBotCallbackQuery) yaerrors.Error ) +// route represents a single route in the router. type route struct { filters []Filter msgHandler MessageHandler cbHandler CallbackHandler } +// Router is the main struct that holds routes, sub-routers, and middlewares. type Router struct { Dependencies @@ -34,6 +38,7 @@ type Router struct { middlewares []HandlerMiddleware } +// Dependencies holds the external dependencies required by the Router. type Dependencies struct { FSMStore yafsm.FSM Log yalogger.Logger @@ -43,6 +48,7 @@ type Dependencies struct { Sender *message.Sender } +// New creates a new Router instance with the given name and dependencies. func New(name string, deps *Dependencies) *Router { if deps == nil { deps = &Dependencies{} @@ -56,8 +62,16 @@ func New(name string, deps *Dependencies) *Router { return r } -func (r *Router) Use(f ...Filter) { r.base = append(r.base, f...) } - +// IncludeRouter includes sub-routers into the current router. +// It sets the parent and inherits dependencies if they are not set. +// +// Example of usage: +// +// subRouter := New("sub", nil) +// +// mainRouter := New("main", YourDependencies) +// +// mainRouter.IncludeRouter(subRouter) func (r *Router) IncludeRouter(subs ...*Router) { for _, s := range subs { s.parent = r @@ -90,6 +104,11 @@ func (r *Router) IncludeRouter(subs ...*Router) { } } +// OnMessage registers a message handler with optional filters. +// +// Example of usage: +// +// router.OnMessage(YourMessageHandler, YourFilter1, YourFilter2) func (r *Router) OnMessage(h MessageHandler, filters ...Filter) { r.routes = append(r.routes, &route{ msgHandler: h, @@ -97,6 +116,11 @@ func (r *Router) OnMessage(h MessageHandler, filters ...Filter) { }) } +// OnCallback registers a callback handler with optional filters. +// +// Example of usage: +// +// router.OnCallback(YourCallbackHandler, YourFilter1, YourFilter2) func (r *Router) OnCallback(h CallbackHandler, filters ...Filter) { r.routes = append(r.routes, &route{ cbHandler: h, diff --git a/yatgbot/router/schema.go b/yatgbot/router/schema.go index 0afb431..be8a10e 100644 --- a/yatgbot/router/schema.go +++ b/yatgbot/router/schema.go @@ -8,6 +8,7 @@ import ( "github.com/gotd/td/tg" ) +// HandlerData holds the dependencies and context for a handler execution. type HandlerData struct { Entities tg.Entities Sender *message.Sender diff --git a/yatgbot/router/updates.go b/yatgbot/router/updates.go index 9c87ca7..58abf48 100644 --- a/yatgbot/router/updates.go +++ b/yatgbot/router/updates.go @@ -6,11 +6,13 @@ import ( "github.com/gotd/td/tg" ) +// Bind binds the router to the given update dispatcher. func (r *Router) Bind(d *tg.UpdateDispatcher) { d.OnNewMessage(r.wrapMessage) d.OnBotCallbackQuery(r.wrapCallback) } +// wrapMessage wraps the message handler to match the expected signature for the update dispatcher. func (r *Router) wrapMessage(ctx context.Context, ent tg.Entities, upd *tg.UpdateNewMessage) error { msg, ok := upd.Message.(*tg.Message) if !ok { @@ -32,6 +34,7 @@ func (r *Router) wrapMessage(ctx context.Context, ent tg.Entities, upd *tg.Updat }) } +// wrapCallback wraps the callback query handler to match the expected signature for the update dispatcher. func (r *Router) wrapCallback( ctx context.Context, ent tg.Entities, diff --git a/yatgbot/router/utils.go b/yatgbot/router/utils.go index 850598f..fed9230 100644 --- a/yatgbot/router/utils.go +++ b/yatgbot/router/utils.go @@ -2,6 +2,7 @@ package router import "github.com/gotd/td/tg" +// extractMessageFromUpdate tries to extract a *tg.Message from the given update. func extractMessageFromUpdate(upd tg.UpdateClass) (*tg.Message, bool) { switch u := upd.(type) { case *tg.UpdateNewMessage: From ea24f5247777d0d5a710751efb33aa344f3c6754 Mon Sep 17 00:00:00 2001 From: Olderestin Date: Thu, 25 Sep 2025 21:47:07 +0300 Subject: [PATCH 07/18] chore(yatgbot): Update docstrings --- yafsm/entityfsm.go | 4 ++ yafsm/fsm.go | 26 ++++++++++++ yafsm/fsm_test.go | 1 + yatgbot/messagequeue/heap.go | 50 +++++++++++++++++++++++ yatgbot/messagequeue/queue.go | 75 +++++++++++++++++++++++++++++++++++ yatgbot/router/filter.go | 19 +++++++++ yatgbot/router/router.go | 5 +++ yatgbot/router/updates.go | 11 +++++ yatgbot/router/utils.go | 9 +++++ 9 files changed, 200 insertions(+) create mode 100644 yafsm/fsm_test.go diff --git a/yafsm/entityfsm.go b/yafsm/entityfsm.go index f0f7181..9d75e73 100644 --- a/yafsm/entityfsm.go +++ b/yafsm/entityfsm.go @@ -13,6 +13,10 @@ type EntityFSMStorage struct { } // NewUserFSMStorage creates a new EntityFSMStorage for a specific user ID. +// +// Example of usage: +// +// userFSMStorage := NewUserFSMStorage(fsmStorage, "user123") func NewUserFSMStorage( storage FSM, uid string, diff --git a/yafsm/fsm.go b/yafsm/fsm.go index e13ae5a..1c38522 100644 --- a/yafsm/fsm.go +++ b/yafsm/fsm.go @@ -58,6 +58,12 @@ type DefaultFSMStorage[T yacache.Container] struct { } // NewDefaultFSMStorage creates a new instance of DefaultFSMStorage. +// +// Example of usage: +// +// cache := yacache.NewCache(redisClient) +// +// fsmStorage := fsm.NewDefaultFSMStorage(cache, fsm.EmptyState{}) func NewDefaultFSMStorage[T yacache.Container]( storage yacache.Cache[T], defaultState State, @@ -69,6 +75,11 @@ func NewDefaultFSMStorage[T yacache.Container]( } // SetState sets the state for a given user ID. +// The state data is marshalled to JSON before being stored. +// +// Example of usage: +// +// err := fsmStorage.SetState(ctx, "123", &SomeState{Field: "value"}) func (b *DefaultFSMStorage[T]) SetState( ctx context.Context, uid string, @@ -99,6 +110,11 @@ func (b *DefaultFSMStorage[T]) SetState( } // 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 of usage: +// +// stateName, stateData, err := fsmStorage.GetState(ctx, "123") func (b *DefaultFSMStorage[T]) GetState( ctx context.Context, uid string, @@ -131,6 +147,16 @@ func (b *DefaultFSMStorage[T]) GetState( } // GetStateData unmarshals the state data into the provided empty state struct. +// +// Example of usage: +// +// var stateData SomeState +// +// err := fsmStorage.GetStateData(marshalledData, &stateData) +// +// if err != nil { +// // handle error +// } func (b *DefaultFSMStorage[T]) GetStateData( stateData StateDataMarshalled, emptyState State, diff --git a/yafsm/fsm_test.go b/yafsm/fsm_test.go new file mode 100644 index 0000000..e3e5919 --- /dev/null +++ b/yafsm/fsm_test.go @@ -0,0 +1 @@ +package yafsm_test diff --git a/yatgbot/messagequeue/heap.go b/yatgbot/messagequeue/heap.go index 5981d6a..baebd89 100644 --- a/yatgbot/messagequeue/heap.go +++ b/yatgbot/messagequeue/heap.go @@ -34,6 +34,16 @@ type JobResult struct { // Execute performs the message sending operation. // If the job is a placeholder, it returns an empty result. // If the job has multiple tasks, it adds empty jobs to the dispatcher. +// +// Example usage: +// +// result := job.Execute(ctx, dispatcher, workerID) +// +// if result.Err != nil { +// // Handle error +// } else { +// // Process result.Updates +// } func (j MessageJob) Execute( ctx context.Context, dispatcher *Dispatcher, @@ -68,6 +78,10 @@ type messageHeap struct { } // newMessageHeap creates a new instance of messageHeap. +// +// Example of usage: +// +// heap := newMessageHeap() func newMessageHeap() messageHeap { return messageHeap{ jobs: make([]MessageJob, 0, HighPriorityQueueSize), @@ -103,6 +117,10 @@ func (h *messageHeap) sort() { } // Push adds a new job to the heap and sorts it. +// +// Example of usage: +// +// heap.Push(job) func (h *messageHeap) Push(job MessageJob) { h.mu.Lock() @@ -113,6 +131,10 @@ func (h *messageHeap) Push(job MessageJob) { } // Len returns the number of jobs in the heap. +// +// Example of usage: +// +// length := heap.Len() func (h *messageHeap) Len() int { h.mu.Lock() defer h.mu.Unlock() @@ -121,6 +143,14 @@ func (h *messageHeap) Len() int { } // Pop removes and returns the highest priority job from the heap. +// +// Example of usage: +// +// job, ok := heap.Pop() +// +// if !ok { +// // Handle empty heap +// } func (h *messageHeap) Pop() (MessageJob, bool) { if h.Len() == 0 { return MessageJob{}, false @@ -138,6 +168,15 @@ func (h *messageHeap) Pop() (MessageJob, bool) { } // Delete removes a job with the specified ID from the heap. +// Returns true if the job was found and deleted, false otherwise. +// +// Example of usage: +// +// deleted := heap.Delete(jobID) +// +// if !deleted { +// // Handle job not found +// } func (h *messageHeap) Delete(id uint64) bool { h.mu.Lock() defer h.mu.Unlock() @@ -154,6 +193,17 @@ func (h *messageHeap) Delete(id uint64) bool { } // DeleteFunc removes jobs that satisfy the given condition from the heap. +// Returns a slice of IDs of the deleted jobs. +// +// Example of usage: +// +// deletedIDs := heap.DeleteFunc(func(job MessageJob) bool { +// return job.Priority < 10 +// }) +// +// if len(deletedIDs) == 0 { +// // Handle no jobs deleted +// } func (h *messageHeap) DeleteFunc(deleteFunc func(MessageJob) bool) []uint64 { var deletedEntries []uint64 diff --git a/yatgbot/messagequeue/queue.go b/yatgbot/messagequeue/queue.go index bd6c7ed..f3eee04 100644 --- a/yatgbot/messagequeue/queue.go +++ b/yatgbot/messagequeue/queue.go @@ -22,6 +22,13 @@ type Dispatcher struct { } // NewDispatcher creates a new Dispatcher with the given number of workers. +// Each worker processes jobs from the priority queue channel. +// The dispatcher uses a condition variable to signal when new jobs are added to the heap. +// It also initializes the message heap and starts the worker goroutines. +// +// Example of usage: +// +// dispatcher := NewDispatcher(ctx, 5, log) func NewDispatcher( ctx context.Context, workerCount uint, @@ -86,16 +93,47 @@ func (d *Dispatcher) worker(ctx context.Context, id uint) { } // DeleteJob removes a job from the heap by its ID. +// Returns true if the job was found and deleted, false otherwise. +// +// Example of usage: +// +// deleted := dispatcher.DeleteJob(jobID) +// +// if !deleted { +// // Handle job not found +// } func (d *Dispatcher) DeleteJob(id uint64) bool { return d.heap.Delete(id) } // DeleteJobFunc removes jobs from the heap that satisfy the given condition. +// +// Example of usage: +// +// deletedIDs := dispatcher.DeleteJobFunc(func(job MessageJob) bool { +// return job.Priority < 10 +// }) +// +// for _, id := range deletedIDs { +// // Handle deleted job ID +// } func (d *Dispatcher) DeleteJobFunc(deleteFunc func(MessageJob) bool) []uint64 { return d.heap.DeleteFunc(deleteFunc) } // AddRawJob adds a raw job to the dispatcher with the specified request, priority, and task count. +// It returns the job ID and a channel to receive the job result. +// +// Example of usage: +// +// jobID, resultCh := dispatcher.AddRawJob(request, priority, taskCount) +// +// // Wait for the job result +// result := <-resultCh +// +// if result.Err != nil { +// // Handle job error +// } func (d *Dispatcher) AddRawJob( request bin.Encoder, priority uint16, @@ -118,6 +156,10 @@ func (d *Dispatcher) AddRawJob( } // AddEmptyJob adds the specified number of placeholder jobs to the dispatcher. +// +// Example of usage: +// +// dispatcher.AddEmptyJob(5) // Adds 5 placeholder jobs func (d *Dispatcher) AddEmptyJob(count uint) { for range count { d.heap.Push(MessageJob{ @@ -127,6 +169,17 @@ func (d *Dispatcher) AddEmptyJob(count uint) { } // AddMessagesForfard adds a message forwarding job to the dispatcher. +// +// Example of usage: +// +// jobID, resultCh := dispatcher.AddMessagesForward(messagesForwardMessagesRequest, priority) +// +// // Wait for the job result +// result := <-resultCh +// +// if result.Err != nil { +// // Handle job error +// } func (d *Dispatcher) AddMessagesForward( req *tg.MessagesForwardMessagesRequest, priority uint16, @@ -140,6 +193,17 @@ func (d *Dispatcher) AddMessagesForward( } // SendMessage adds a message sending job to the dispatcher. +// +// Example of usage: +// +// jobID, resultCh := dispatcher.SendMessage(messagesSendMessageRequest, priority) +// +// // Wait for the job result +// result := <-resultCh +// +// if result.Err != nil { +// // Handle job error +// } func (d *Dispatcher) SendMessage( req *tg.MessagesSendMessageRequest, priority uint16, @@ -152,6 +216,17 @@ func (d *Dispatcher) SendMessage( } // SendMedia adds a media sending job to the dispatcher. +// +// Example of usage: +// +// jobID, resultCh := dispatcher.SendMedia(messagesSendMediaRequest, priority) +// +// // Wait for the job result +// result := <-resultCh +// +// if result.Err != nil { +// // Handle job error +// } func (d *Dispatcher) SendMultiMedia( req *tg.MessagesSendMultiMediaRequest, priority uint16, diff --git a/yatgbot/router/filter.go b/yatgbot/router/filter.go index abd4c32..41e5233 100644 --- a/yatgbot/router/filter.go +++ b/yatgbot/router/filter.go @@ -23,6 +23,10 @@ type FilterDependencies struct { } // StateIs creates a filter that checks if the user's state matches any of the provided states. +// +// Example of usage: +// +// router.OnMessage(YourMessageHandler, router.StateIs("StateA", "StateB")) func StateIs(want ...string) Filter { wanted := make(map[string]struct{}, len(want)) @@ -46,6 +50,10 @@ func StateIs(want ...string) Filter { } // TextEq creates a filter that checks if the message text equals the specified string. +// +// Example of usage: +// +// router.OnMessage(YourMessageHandler, router.TextEq("Hello")) func TextEq(want string) Filter { return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { if m, ok := extractMessageFromUpdate(deps.update); ok && m.Message == want { @@ -57,6 +65,10 @@ func TextEq(want string) Filter { } // TextRegex creates a filter that checks if the message text matches the specified regex. +// +// Example of usage: +// +// router.OnMessage(YourMessageHandler, router.TextRegex(regexp.MustCompile(`^Hello.*`))) func TextRegex(re *regexp.Regexp) Filter { return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { if m, ok := extractMessageFromUpdate(deps.update); ok && re.MatchString(m.Message) { @@ -68,6 +80,10 @@ func TextRegex(re *regexp.Regexp) Filter { } // CallbackEq creates a filter that checks if the callback query data equals the specified string. +// +// Example of usage: +// +// router.OnCallback(YourCallbackHandler, router.CallbackEq("some_data")) func CallbackEq(data string) Filter { return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { if q, ok := deps.update.(*tg.UpdateBotCallbackQuery); ok && string(q.Data) == data { @@ -79,6 +95,9 @@ func CallbackEq(data string) Filter { } // CallbackPrefix creates a filter that checks if the callback query data starts with the specified prefix. +// +// Example of usage: +// router.OnCallback(YourCallbackHandler, router.CallbackPrefix("prefix_")) func CallbackPrefix(prefix string) Filter { return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { if q, ok := deps.update.(*tg.UpdateBotCallbackQuery); ok && diff --git a/yatgbot/router/router.go b/yatgbot/router/router.go index 4114ae8..cc215b8 100644 --- a/yatgbot/router/router.go +++ b/yatgbot/router/router.go @@ -49,6 +49,11 @@ type Dependencies struct { } // New creates a new Router instance with the given name and dependencies. +// If deps is nil, it initializes with default zero values. +// +// Example of usage: +// +// r := router.New("main", YourDependencies) func New(name string, deps *Dependencies) *Router { if deps == nil { deps = &Dependencies{} diff --git a/yatgbot/router/updates.go b/yatgbot/router/updates.go index 58abf48..6633408 100644 --- a/yatgbot/router/updates.go +++ b/yatgbot/router/updates.go @@ -7,6 +7,17 @@ import ( ) // Bind binds the router to the given update dispatcher. +// It sets up updates handling for bot. +// It should be called once during the bot setup. +// After calling this method, the router will start receiving updates +// and dispatching them to the appropriate handlers based on the defined routes and filters. +// +// Example of usage: +// +// // dispatcher := tg.NewUpdateDispatcher(yourClient) +// +// r := router.New("main", YourDependencies) +// r.Bind(dispatcher) func (r *Router) Bind(d *tg.UpdateDispatcher) { d.OnNewMessage(r.wrapMessage) d.OnBotCallbackQuery(r.wrapCallback) diff --git a/yatgbot/router/utils.go b/yatgbot/router/utils.go index fed9230..ebdad52 100644 --- a/yatgbot/router/utils.go +++ b/yatgbot/router/utils.go @@ -3,6 +3,15 @@ package router import "github.com/gotd/td/tg" // extractMessageFromUpdate tries to extract a *tg.Message from the given update. +// It returns the message and true if successful, otherwise nil and false. +// +// Example of usage: +// +// msg, ok := extractMessageFromUpdate(update) +// +// if ok { +// // process msg +// } func extractMessageFromUpdate(upd tg.UpdateClass) (*tg.Message, bool) { switch u := upd.(type) { case *tg.UpdateNewMessage: From bbff65299cc20dbf160f57cdb07ba578a0006d11 Mon Sep 17 00:00:00 2001 From: Olderestin Date: Thu, 25 Sep 2025 23:24:25 +0300 Subject: [PATCH 08/18] fix(yatgbot): Correct `stateDataKey` constant --- yafsm/consts.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yafsm/consts.go b/yafsm/consts.go index 3c730cb..a2ce750 100644 --- a/yafsm/consts.go +++ b/yafsm/consts.go @@ -2,5 +2,5 @@ package yafsm const ( stateKey = "state" - stateDataKey = "state_data" + stateDataKey = "stateData" ) From b702542a1c338fa1f149f2ed99105221071d95be Mon Sep 17 00:00:00 2001 From: Olderestin Date: Thu, 25 Sep 2025 23:24:38 +0300 Subject: [PATCH 09/18] test(yatgbot): Add unit tests --- yafsm/fsm_test.go | 100 ++++++++++++++++++++++++++++++ yatgbot/messagequeue/heap_test.go | 93 +++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 yatgbot/messagequeue/heap_test.go diff --git a/yafsm/fsm_test.go b/yafsm/fsm_test.go index e3e5919..c8b3c51 100644 --- a/yafsm/fsm_test.go +++ b/yafsm/fsm_test.go @@ -1 +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) + } +} diff --git a/yatgbot/messagequeue/heap_test.go b/yatgbot/messagequeue/heap_test.go new file mode 100644 index 0000000..e5c0f13 --- /dev/null +++ b/yatgbot/messagequeue/heap_test.go @@ -0,0 +1,93 @@ +package messagequeue + +import ( + "testing" + "time" +) + +func mustPop(t *testing.T, h *messageHeap) MessageJob { + t.Helper() + + job, ok := h.Pop() + if !ok { + t.Fatalf("expected job, heap is empty") + } + + return job +} + +func TestHeap_PushPopOrdering(t *testing.T) { + h := newMessageHeap() + now := time.Now() + + // ID 3: highest priority (1) and *oldest* timestamp + h.Push(MessageJob{ID: 3, Priority: 1, Timestamp: now.Add(-2 * time.Minute)}) + // ID 2: same priority 1 but newer than ID 3 + h.Push(MessageJob{ID: 2, Priority: 1, Timestamp: now}) + // ID 1: lower priority (2) + h.Push(MessageJob{ID: 1, Priority: 2, Timestamp: now}) + // ID 4: placeholder – always first + h.Push(MessageJob{ID: 4, IsPlaceholder: true}) + + if h.Len() != 4 { + t.Fatalf("expected heap len 4, got %d", h.Len()) + } + + wantOrder := []uint64{4, 3, 2, 1} + for i, wantID := range wantOrder { + got := mustPop(t, &h) + if got.ID != wantID { + t.Fatalf("pop #%d: want ID %d, got %d", i+1, wantID, got.ID) + } + } + + if h.Len() != 0 { + t.Fatalf("expected empty heap, len=%d", h.Len()) + } +} + +func TestHeap_DeleteByID(t *testing.T) { + h := newMessageHeap() + h.Push(MessageJob{ID: 10}) + h.Push(MessageJob{ID: 20}) + + if !h.Delete(10) { + t.Fatalf("Delete should return true for existing ID") + } + + if h.Len() != 1 { + t.Fatalf("expected len 1 after delete, got %d", h.Len()) + } + + if h.Delete(42) { + t.Fatalf("Delete should return false for missing ID") + } +} + +func TestHeap_DeleteFunc(t *testing.T) { + h := newMessageHeap() + + h.Push(MessageJob{ID: 1, Priority: 5}) + h.Push(MessageJob{ID: 2, Priority: 3}) + h.Push(MessageJob{ID: 3, Priority: 1}) + + deleted := h.DeleteFunc(func(j MessageJob) bool { return j.Priority < 4 }) + if len(deleted) != 2 { + t.Fatalf("expected 2 jobs deleted, got %d", len(deleted)) + } + + if h.Len() != 1 { + t.Fatalf("expected heap len 1 after DeleteFunc, got %d", h.Len()) + } + + if deleted[0] == deleted[1] { + t.Fatalf("deleted IDs should be unique, got %+v", deleted) + } +} + +func TestHeap_PopOnEmpty(t *testing.T) { + h := newMessageHeap() + if _, ok := h.Pop(); ok { + t.Fatalf("expected ok==false on empty Pop") + } +} From 4605d2f0538c1d8b04c98ed54e5c57f9b8353690 Mon Sep 17 00:00:00 2001 From: Olderestin Date: Fri, 26 Sep 2025 01:04:27 +0300 Subject: [PATCH 10/18] refactor(yatgbot): Add yalocales --- go.mod | 2 +- yatgbot/localizer/localizer.go | 85 ---------------------------------- yatgbot/router/dispatcher.go | 24 ++++++++-- yatgbot/router/router.go | 4 +- yatgbot/router/schema.go | 3 +- 5 files changed, 24 insertions(+), 94 deletions(-) delete mode 100644 yatgbot/localizer/localizer.go diff --git a/go.mod b/go.mod index d16bdca..b49de10 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,6 @@ require ( github.com/redis/go-redis/v9 v9.11.0 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b golang.org/x/net v0.42.0 golang.org/x/text v0.27.0 gorm.io/driver/sqlite v1.6.0 @@ -54,6 +53,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/crypto v0.40.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.26.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.34.0 // indirect diff --git a/yatgbot/localizer/localizer.go b/yatgbot/localizer/localizer.go deleted file mode 100644 index 17b77ae..0000000 --- a/yatgbot/localizer/localizer.go +++ /dev/null @@ -1,85 +0,0 @@ -package localizer - -import ( - "encoding/json" - "fmt" - "io/fs" - "net/http" - "path/filepath" - "strings" - - "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" -) - -type Localizer struct { - strings map[string]map[string]string - defaultLang string -} - -func NewLocalizer(fsys fs.FS, defaultLang string) (*Localizer, yaerrors.Error) { - loc := &Localizer{ - strings: make(map[string]map[string]string), - defaultLang: defaultLang, - } - - err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - - if !strings.HasSuffix(path, ".json") { - return nil - } - - lang := strings.TrimSuffix(filepath.Base(path), ".json") - - bytes, err := fs.ReadFile(fsys, path) - if err != nil { - return fmt.Errorf("read %s: %w", path, err) - } - - var data map[string]string - if err := json.Unmarshal(bytes, &data); err != nil { - return fmt.Errorf("decode %s: %w", path, err) - } - - loc.strings[lang] = data - - return nil - }) - if err != nil { - return nil, yaerrors.FromError( - http.StatusInternalServerError, - err, - "failed to walk directory", - ) - } - - return loc, nil -} - -func (l *Localizer) Lang(lang string) func(key string) string { - return func(key string) string { - if val, ok := l.strings[lang][key]; ok { - return val - } - - if val, ok := l.strings[l.defaultLang][key]; ok { - return val - } - - return key - } -} - -func (l *Localizer) T(lang, key string) string { - if val, ok := l.strings[lang][key]; ok { - return val - } - - if val, ok := l.strings[l.defaultLang][key]; ok { - return val - } - - return key -} diff --git a/yatgbot/router/dispatcher.go b/yatgbot/router/dispatcher.go index 9cd9d07..1ca8739 100644 --- a/yatgbot/router/dispatcher.go +++ b/yatgbot/router/dispatcher.go @@ -2,10 +2,12 @@ package router import ( "context" + "net/http" "strconv" "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" "github.com/YaCodeDev/GoYaCodeDevUtils/yafsm" + "github.com/YaCodeDev/GoYaCodeDevUtils/yalocales" "github.com/gotd/td/tg" ) @@ -38,7 +40,7 @@ func (r *Router) dispatch(ctx context.Context, deps DispatcherDependencies) yaer }, rt.filters) if err != nil { - return yaerrors.FromErrorWithLog(0, err, "failed to apply filters", r.Log) + return yaerrors.FromErrorWithLog(http.StatusInternalServerError, err, "failed to apply filters", r.Log) } if !ok { @@ -46,11 +48,23 @@ func (r *Router) dispatch(ctx context.Context, deps DispatcherDependencies) yaer continue } - - var lang func(string) string + var localizer yalocales.Localizer if user, ok := deps.ent.Users[deps.userID]; ok && user.LangCode != "" { - lang = r.Localizer.Lang(user.LangCode) + localizer, err = r.Localizer.DeriveNewDefaultLang(user.LangCode) + + if err != nil { + if err != yalocales.ErrInvalidLanguage { + return yaerrors.FromErrorWithLog( + http.StatusInternalServerError, + err, + "failed to derive localizer", + r.Log, + ) + } + + localizer = r.Localizer + } r.Log.Debugf("Using user %d language: %s", deps.userID, user.LangCode) } @@ -63,7 +77,7 @@ func (r *Router) dispatch(ctx context.Context, deps DispatcherDependencies) yaer StateStorage: userFSMStorage, Log: r.Log, Dispatcher: r.MessageDispatcher, - T: lang, + Localizer: localizer, Client: r.Client, } diff --git a/yatgbot/router/router.go b/yatgbot/router/router.go index cc215b8..b33cbb7 100644 --- a/yatgbot/router/router.go +++ b/yatgbot/router/router.go @@ -5,8 +5,8 @@ import ( "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" "github.com/YaCodeDev/GoYaCodeDevUtils/yafsm" + "github.com/YaCodeDev/GoYaCodeDevUtils/yalocales" "github.com/YaCodeDev/GoYaCodeDevUtils/yalogger" - "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/localizer" "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/messagequeue" "github.com/gotd/td/telegram/message" "github.com/gotd/td/tg" @@ -43,7 +43,7 @@ type Dependencies struct { FSMStore yafsm.FSM Log yalogger.Logger MessageDispatcher *messagequeue.Dispatcher - Localizer *localizer.Localizer + Localizer yalocales.Localizer Client *tg.Client Sender *message.Sender } diff --git a/yatgbot/router/schema.go b/yatgbot/router/schema.go index be8a10e..b8fe2a6 100644 --- a/yatgbot/router/schema.go +++ b/yatgbot/router/schema.go @@ -2,6 +2,7 @@ package router import ( "github.com/YaCodeDev/GoYaCodeDevUtils/yafsm" + "github.com/YaCodeDev/GoYaCodeDevUtils/yalocales" "github.com/YaCodeDev/GoYaCodeDevUtils/yalogger" "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/messagequeue" "github.com/gotd/td/telegram/message" @@ -19,5 +20,5 @@ type HandlerData struct { StateStorage *yafsm.EntityFSMStorage Log yalogger.Logger Dispatcher *messagequeue.Dispatcher - T func(string) string // localizer + Localizer yalocales.Localizer } From 8c2ee6a2b41276efd21bc750949a419e2c74b801 Mon Sep 17 00:00:00 2001 From: Olderestin Date: Fri, 26 Sep 2025 01:09:08 +0300 Subject: [PATCH 11/18] chore(yatgbot): Improve code --- yatgbot/router/dispatcher.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/yatgbot/router/dispatcher.go b/yatgbot/router/dispatcher.go index 1ca8739..805e85b 100644 --- a/yatgbot/router/dispatcher.go +++ b/yatgbot/router/dispatcher.go @@ -2,6 +2,7 @@ package router import ( "context" + "errors" "net/http" "strconv" @@ -40,7 +41,12 @@ func (r *Router) dispatch(ctx context.Context, deps DispatcherDependencies) yaer }, rt.filters) if err != nil { - return yaerrors.FromErrorWithLog(http.StatusInternalServerError, err, "failed to apply filters", r.Log) + return yaerrors.FromErrorWithLog( + http.StatusInternalServerError, + err, + "failed to apply filters", + r.Log, + ) } if !ok { @@ -48,13 +54,13 @@ func (r *Router) dispatch(ctx context.Context, deps DispatcherDependencies) yaer continue } + var localizer yalocales.Localizer if user, ok := deps.ent.Users[deps.userID]; ok && user.LangCode != "" { localizer, err = r.Localizer.DeriveNewDefaultLang(user.LangCode) - if err != nil { - if err != yalocales.ErrInvalidLanguage { + if !errors.Is(err, yalocales.ErrInvalidLanguage) { return yaerrors.FromErrorWithLog( http.StatusInternalServerError, err, @@ -65,6 +71,7 @@ func (r *Router) dispatch(ctx context.Context, deps DispatcherDependencies) yaer localizer = r.Localizer } + r.Log.Debugf("Using user %d language: %s", deps.userID, user.LangCode) } From 62e65030a2a98206886b7a6539419eb95e5f0a41 Mon Sep 17 00:00:00 2001 From: Olderestin Date: Thu, 4 Dec 2025 04:21:03 +0200 Subject: [PATCH 12/18] feat(yatgbot): Support new tg updates Also improve file structure --- yafsm/{consts.go => constants.go} | 0 yafsm/entityfsm.go | 6 +- yafsm/fsm.go | 22 +- yatgbot/{router => }/dispatcher.go | 92 +++-- yatgbot/{router => }/filter.go | 68 +++- yatgbot/messagequeue/constants.go | 6 + yatgbot/messagequeue/consts.go | 6 - yatgbot/messagequeue/heap.go | 35 +- .../{queue.go => messagequeue.go} | 162 +++++---- yatgbot/{router => }/middlewares.go | 24 +- yatgbot/router.go | 184 ++++++++++ yatgbot/router/router.go | 134 ------- yatgbot/router/schema.go | 24 -- yatgbot/router/updates.go | 65 ---- yatgbot/router/utils.go | 28 -- yatgbot/updates.go | 332 ++++++++++++++++++ yatgbot/utils.go | 43 +++ yatgbot/yatgbot.go | 119 +++++++ 18 files changed, 944 insertions(+), 406 deletions(-) rename yafsm/{consts.go => constants.go} (100%) rename yatgbot/{router => }/dispatcher.go (65%) rename yatgbot/{router => }/filter.go (66%) create mode 100644 yatgbot/messagequeue/constants.go delete mode 100644 yatgbot/messagequeue/consts.go rename yatgbot/messagequeue/{queue.go => messagequeue.go} (72%) rename yatgbot/{router => }/middlewares.go (74%) create mode 100644 yatgbot/router.go delete mode 100644 yatgbot/router/router.go delete mode 100644 yatgbot/router/schema.go delete mode 100644 yatgbot/router/updates.go delete mode 100644 yatgbot/router/utils.go create mode 100644 yatgbot/updates.go create mode 100644 yatgbot/utils.go create mode 100644 yatgbot/yatgbot.go diff --git a/yafsm/consts.go b/yafsm/constants.go similarity index 100% rename from yafsm/consts.go rename to yafsm/constants.go diff --git a/yafsm/entityfsm.go b/yafsm/entityfsm.go index 9d75e73..65a7f9e 100644 --- a/yafsm/entityfsm.go +++ b/yafsm/entityfsm.go @@ -14,7 +14,7 @@ type EntityFSMStorage struct { // NewUserFSMStorage creates a new EntityFSMStorage for a specific user ID. // -// Example of usage: +// Example usage: // // userFSMStorage := NewUserFSMStorage(fsmStorage, "user123") func NewUserFSMStorage( @@ -54,7 +54,7 @@ func (b *EntityFSMStorage) SetState( // } func (b *EntityFSMStorage) GetState( ctx context.Context, -) (string, StateDataMarshalled, yaerrors.Error) { +) (string, stateDataMarshalled, yaerrors.Error) { return b.storage.GetState(ctx, b.uid) } @@ -70,7 +70,7 @@ func (b *EntityFSMStorage) GetState( // // handle error // } func (b *EntityFSMStorage) GetStateData( - stateData StateDataMarshalled, + stateData stateDataMarshalled, emptyState State, ) yaerrors.Error { return b.storage.GetStateData(stateData, emptyState) diff --git a/yafsm/fsm.go b/yafsm/fsm.go index 1c38522..881e464 100644 --- a/yafsm/fsm.go +++ b/yafsm/fsm.go @@ -35,8 +35,8 @@ type EmptyState struct { BaseState[EmptyState] } -// StateDataMarshalled is a type alias for marshalled state data. -type StateDataMarshalled string +// 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 { @@ -47,8 +47,8 @@ type StateAndData struct { // 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 + 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. @@ -59,7 +59,7 @@ type DefaultFSMStorage[T yacache.Container] struct { // NewDefaultFSMStorage creates a new instance of DefaultFSMStorage. // -// Example of usage: +// Example usage: // // cache := yacache.NewCache(redisClient) // @@ -77,7 +77,7 @@ func NewDefaultFSMStorage[T yacache.Container]( // SetState sets the state for a given user ID. // The state data is marshalled to JSON before being stored. // -// Example of usage: +// Example usage: // // err := fsmStorage.SetState(ctx, "123", &SomeState{Field: "value"}) func (b *DefaultFSMStorage[T]) SetState( @@ -112,13 +112,13 @@ func (b *DefaultFSMStorage[T]) SetState( // 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 of usage: +// Example usage: // // stateName, stateData, err := fsmStorage.GetState(ctx, "123") func (b *DefaultFSMStorage[T]) GetState( ctx context.Context, uid string, -) (string, StateDataMarshalled, yaerrors.Error) { +) (string, stateDataMarshalled, yaerrors.Error) { data, err := b.storage.Get(ctx, uid) if err != nil { return b.defaultState.StateName(), "", nil @@ -143,12 +143,12 @@ func (b *DefaultFSMStorage[T]) GetState( ) } - return state, StateDataMarshalled(data), nil + return state, stateDataMarshalled(data), nil } // GetStateData unmarshals the state data into the provided empty state struct. // -// Example of usage: +// Example usage: // // var stateData SomeState // @@ -158,7 +158,7 @@ func (b *DefaultFSMStorage[T]) GetState( // // handle error // } func (b *DefaultFSMStorage[T]) GetStateData( - stateData StateDataMarshalled, + stateData stateDataMarshalled, emptyState State, ) yaerrors.Error { if stateData == "" { diff --git a/yatgbot/router/dispatcher.go b/yatgbot/dispatcher.go similarity index 65% rename from yatgbot/router/dispatcher.go rename to yatgbot/dispatcher.go index 805e85b..9d2ff09 100644 --- a/yatgbot/router/dispatcher.go +++ b/yatgbot/dispatcher.go @@ -1,4 +1,4 @@ -package router +package yatgbot import ( "context" @@ -9,11 +9,24 @@ import ( "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" "github.com/YaCodeDev/GoYaCodeDevUtils/yafsm" "github.com/YaCodeDev/GoYaCodeDevUtils/yalocales" + "github.com/YaCodeDev/GoYaCodeDevUtils/yalogger" + "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/messagequeue" + "github.com/YaCodeDev/GoYaCodeDevUtils/yatgclient" "github.com/gotd/td/tg" ) -// DispatcherDependencies holds the dependencies required for dispatching an update. -type DispatcherDependencies struct { +type Dispatcher struct { + FSMStore yafsm.FSM + Log yalogger.Logger + BotUser *tg.User + MessageDispatcher *messagequeue.Dispatcher + Localizer yalocales.Localizer + Client *yatgclient.Client + MainRouter *RouterGroup +} + +// UpdateData holds the dependencies required for dispatching an update. +type UpdateData struct { userID int64 chatID int64 ent tg.Entities @@ -23,7 +36,7 @@ type DispatcherDependencies struct { // dispatch processes the update by checking filters and executing the appropriate handler. // It also supports nested routers by dispatching to sub-routers if no local route matches. -func (r *Router) dispatch(ctx context.Context, deps DispatcherDependencies) yaerrors.Error { +func (r *Dispatcher) dispatch(ctx context.Context, deps UpdateData) yaerrors.Error { userFSMStorage := yafsm.NewUserFSMStorage( r.FSMStore, strconv.FormatInt(deps.chatID, 10), @@ -31,7 +44,7 @@ func (r *Router) dispatch(ctx context.Context, deps DispatcherDependencies) yaer r.Log.Debugf("Processing update: %+v with entities: %+v", deps.update, deps.ent) - for _, rt := range r.routes { + for _, rt := range r.MainRouter.routes { ok, err := r.checkFilters( ctx, FilterDependencies{ @@ -41,12 +54,7 @@ func (r *Router) dispatch(ctx context.Context, deps DispatcherDependencies) yaer }, rt.filters) if err != nil { - return yaerrors.FromErrorWithLog( - http.StatusInternalServerError, - err, - "failed to apply filters", - r.Log, - ) + return yaerrors.FromErrorWithLog(http.StatusInternalServerError, err, "failed to apply filters", r.Log) } if !ok { @@ -77,7 +85,6 @@ func (r *Router) dispatch(ctx context.Context, deps DispatcherDependencies) yaer hdata := &HandlerData{ Entities: deps.ent, - Sender: r.Sender, Update: deps.update, UserID: deps.userID, Peer: deps.inputPeer, @@ -88,18 +95,23 @@ func (r *Router) dispatch(ctx context.Context, deps DispatcherDependencies) yaer Client: r.Client, } - switch u := deps.update.(type) { - case *tg.UpdateNewMessage: - return chainMiddleware(wrapHandler(rt.msgHandler), r.collectMiddlewares()...)(ctx, hdata, u) - case *tg.UpdateBotCallbackQuery: - return chainMiddleware(wrapHandler(rt.cbHandler), r.collectMiddlewares()...)(ctx, hdata, u) + err = chainMiddleware(rt.handler, r.MainRouter.collectMiddlewares()...)(ctx, hdata, deps.update) + if err != nil { + if errors.Is(err, ErrRouteMismatch) { + continue + } + return err.Wrap("handler execution failed") } + + return nil } - for _, sub := range r.sub { - err := sub.dispatch(ctx, deps) + for _, sub := range r.MainRouter.sub { + r.MainRouter = sub + + err := r.dispatch(ctx, deps) if err != nil { - return err + return err.Wrap("sub-router dispatch failed") } } @@ -107,29 +119,45 @@ func (r *Router) dispatch(ctx context.Context, deps DispatcherDependencies) yaer } // checkFilters checks the filters of the current router and its parents recursively. -func (r *Router) checkFilters( +func (r *Dispatcher) checkFilters( ctx context.Context, deps FilterDependencies, local []Filter, ) (bool, yaerrors.Error) { - if r.parent != nil { - ok, err := r.parent.checkFilters(ctx, deps, nil) - if err != nil || !ok { - return ok, err.Wrap("parent filter check failed") - } + // 1) Build the chain from current group up to root. + var chain []*RouterGroup + for g := r.MainRouter; g != nil; g = g.parent { + chain = append(chain, g) } - for _, f := range r.base { - ok, err := f(ctx, deps) - if err != nil || !ok { - return ok, err.Wrap("base filter check failed") + // 2) Reverse so we run filters from root -> current. + for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 { + chain[i], chain[j] = chain[j], chain[i] + } + + // 3) Run base filters in that order. + for _, grp := range chain { + for _, f := range grp.base { + ok, err := f(ctx, deps) + if err != nil { + return false, err.Wrap("base filter check failed") + } + + if !ok { + return false, nil + } } } + // 4) Run local (route) filters last. for _, f := range local { ok, err := f(ctx, deps) - if err != nil || !ok { - return ok, err.Wrap("local filter check failed") + if err != nil { + return false, err.Wrap("local filter check failed") + } + + if !ok { + return false, nil } } diff --git a/yatgbot/router/filter.go b/yatgbot/filter.go similarity index 66% rename from yatgbot/router/filter.go rename to yatgbot/filter.go index 41e5233..264a5ce 100644 --- a/yatgbot/router/filter.go +++ b/yatgbot/filter.go @@ -1,4 +1,4 @@ -package router +package yatgbot import ( "context" @@ -24,7 +24,7 @@ type FilterDependencies struct { // StateIs creates a filter that checks if the user's state matches any of the provided states. // -// Example of usage: +// Example usage: // // router.OnMessage(YourMessageHandler, router.StateIs("StateA", "StateB")) func StateIs(want ...string) Filter { @@ -51,12 +51,12 @@ func StateIs(want ...string) Filter { // TextEq creates a filter that checks if the message text equals the specified string. // -// Example of usage: +// Example usage: // // router.OnMessage(YourMessageHandler, router.TextEq("Hello")) func TextEq(want string) Filter { return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { - if m, ok := extractMessageFromUpdate(deps.update); ok && m.Message == want { + if m, ok := ExtractMessageFromUpdate(deps.update); ok && m.Message == want { return true, nil } @@ -66,12 +66,12 @@ func TextEq(want string) Filter { // TextRegex creates a filter that checks if the message text matches the specified regex. // -// Example of usage: +// Example usage: // // router.OnMessage(YourMessageHandler, router.TextRegex(regexp.MustCompile(`^Hello.*`))) func TextRegex(re *regexp.Regexp) Filter { return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { - if m, ok := extractMessageFromUpdate(deps.update); ok && re.MatchString(m.Message) { + if m, ok := ExtractMessageFromUpdate(deps.update); ok && re.MatchString(m.Message) { return true, nil } @@ -81,7 +81,7 @@ func TextRegex(re *regexp.Regexp) Filter { // CallbackEq creates a filter that checks if the callback query data equals the specified string. // -// Example of usage: +// Example usage: // // router.OnCallback(YourCallbackHandler, router.CallbackEq("some_data")) func CallbackEq(data string) Filter { @@ -96,7 +96,7 @@ func CallbackEq(data string) Filter { // CallbackPrefix creates a filter that checks if the callback query data starts with the specified prefix. // -// Example of usage: +// Example usage: // router.OnCallback(YourCallbackHandler, router.CallbackPrefix("prefix_")) func CallbackPrefix(prefix string) Filter { return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { @@ -108,3 +108,55 @@ func CallbackPrefix(prefix string) Filter { return false, nil } } + +func MessageServiceActionFilter[T tg.MessageActionClass]() Filter { + return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { + if messageService, ok := ExtractMessageServiceFromUpdate(deps.update); ok { + _, ok := messageService.Action.(T) + return ok, nil + } + + return false, nil + } +} + +func MessageServiceFilter() Filter { + return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { + _, ok := ExtractMessageServiceFromUpdate(deps.update) + return ok, nil + } +} + +func OneOfFilter(filters ...Filter) Filter { + return func(ctx context.Context, deps FilterDependencies) (bool, yaerrors.Error) { + for _, f := range filters { + ok, err := f(ctx, deps) + if err != nil { + return false, err.Wrap("or-filter check failed") + } + + if ok { + return true, nil + } + } + + return false, nil + } +} + +func AllOfFilter(filters ...Filter) Filter { + return func(ctx context.Context, deps FilterDependencies) (bool, yaerrors.Error) { + for _, f := range filters { + ok, err := f(ctx, deps) + if err != nil { + return false, err.Wrap("and-filter check failed") + } + + if !ok { + return false, nil + } + } + + return true, nil + } +} diff --git a/yatgbot/messagequeue/constants.go b/yatgbot/messagequeue/constants.go new file mode 100644 index 0000000..040e1c1 --- /dev/null +++ b/yatgbot/messagequeue/constants.go @@ -0,0 +1,6 @@ +package messagequeue + +const ( + PriorityQueueAllocSize = 1024 + SingleMessage = 1 +) diff --git a/yatgbot/messagequeue/consts.go b/yatgbot/messagequeue/consts.go deleted file mode 100644 index b9e0f0c..0000000 --- a/yatgbot/messagequeue/consts.go +++ /dev/null @@ -1,6 +0,0 @@ -package messagequeue - -const ( - HighPriorityQueueSize = 1024 - SingleMessage = 1 -) diff --git a/yatgbot/messagequeue/heap.go b/yatgbot/messagequeue/heap.go index baebd89..d1aba94 100644 --- a/yatgbot/messagequeue/heap.go +++ b/yatgbot/messagequeue/heap.go @@ -33,7 +33,7 @@ type JobResult struct { // Execute performs the message sending operation. // If the job is a placeholder, it returns an empty result. -// If the job has multiple tasks, it adds empty jobs to the dispatcher. +// If the job has multiple tasks, it adds empty jobs to the dispatcher to compensate for it. // // Example usage: // @@ -49,6 +49,8 @@ func (j MessageJob) Execute( dispatcher *Dispatcher, workerID uint, ) JobResult { + var yaErr yaerrors.Error + if j.IsPlaceholder { return JobResult{} } @@ -60,14 +62,17 @@ func (j MessageJob) Execute( var result tg.UpdatesBox err := dispatcher.Client.Invoke(ctx, j.Request, &result) - - return JobResult{ - Updates: result.Updates, - Err: yaerrors.FromError( + if err != nil { + yaErr = yaerrors.FromError( http.StatusInternalServerError, err, fmt.Sprintf("worker %d: failed to send message", workerID), - ), + ) + } + + return JobResult{ + Updates: result.Updates, + Err: yaErr, } } @@ -79,12 +84,12 @@ type messageHeap struct { // newMessageHeap creates a new instance of messageHeap. // -// Example of usage: +// Example usage: // // heap := newMessageHeap() func newMessageHeap() messageHeap { return messageHeap{ - jobs: make([]MessageJob, 0, HighPriorityQueueSize), + jobs: make([]MessageJob, 0, PriorityQueueAllocSize), } } @@ -101,6 +106,10 @@ func (h *messageHeap) sort() { return 1 } + if b.IsPlaceholder { + return -1 + } + if a.Priority != b.Priority { return cmp.Compare(b.Priority, a.Priority) } @@ -118,7 +127,7 @@ func (h *messageHeap) sort() { // Push adds a new job to the heap and sorts it. // -// Example of usage: +// Example usage: // // heap.Push(job) func (h *messageHeap) Push(job MessageJob) { @@ -132,7 +141,7 @@ func (h *messageHeap) Push(job MessageJob) { // Len returns the number of jobs in the heap. // -// Example of usage: +// Example usage: // // length := heap.Len() func (h *messageHeap) Len() int { @@ -144,7 +153,7 @@ func (h *messageHeap) Len() int { // Pop removes and returns the highest priority job from the heap. // -// Example of usage: +// Example usage: // // job, ok := heap.Pop() // @@ -170,7 +179,7 @@ func (h *messageHeap) Pop() (MessageJob, bool) { // Delete removes a job with the specified ID from the heap. // Returns true if the job was found and deleted, false otherwise. // -// Example of usage: +// Example usage: // // deleted := heap.Delete(jobID) // @@ -195,7 +204,7 @@ func (h *messageHeap) Delete(id uint64) bool { // DeleteFunc removes jobs that satisfy the given condition from the heap. // Returns a slice of IDs of the deleted jobs. // -// Example of usage: +// Example usage: // // deletedIDs := heap.DeleteFunc(func(job MessageJob) bool { // return job.Priority < 10 diff --git a/yatgbot/messagequeue/queue.go b/yatgbot/messagequeue/messagequeue.go similarity index 72% rename from yatgbot/messagequeue/queue.go rename to yatgbot/messagequeue/messagequeue.go index f3eee04..8846c98 100644 --- a/yatgbot/messagequeue/queue.go +++ b/yatgbot/messagequeue/messagequeue.go @@ -14,11 +14,11 @@ import ( // Dispatcher handles message sending with priority and concurrency control. type Dispatcher struct { - Client yatgclient.Client - priorityQueueChannel chan MessageJob - heap messageHeap - cond sync.Cond - log yalogger.Logger + Client *yatgclient.Client + messageJobChannel chan MessageJob // TODO: rename channel name + heap messageHeap + cond sync.Cond + log yalogger.Logger } // NewDispatcher creates a new Dispatcher with the given number of workers. @@ -26,18 +26,21 @@ type Dispatcher struct { // The dispatcher uses a condition variable to signal when new jobs are added to the heap. // It also initializes the message heap and starts the worker goroutines. // -// Example of usage: +// Example usage: // // dispatcher := NewDispatcher(ctx, 5, log) func NewDispatcher( ctx context.Context, + client *yatgclient.Client, workerCount uint, log yalogger.Logger, ) *Dispatcher { dispatcher := &Dispatcher{ - priorityQueueChannel: make(chan MessageJob), - log: log, - heap: newMessageHeap(), + Client: client, + messageJobChannel: make(chan MessageJob), + log: log, + heap: newMessageHeap(), + cond: *sync.NewCond(&sync.Mutex{}), } go dispatcher.proccessMessagesQueue() @@ -49,55 +52,12 @@ func NewDispatcher( return dispatcher } -// proccessMessagesQueue continuously processes jobs from the heap and sends them to the priority queue channel. -// It waits for new jobs if the heap is empty. -func (d *Dispatcher) proccessMessagesQueue() { - for { - if d.heap.Len() == 0 { - d.cond.L.Lock() - d.cond.Wait() - d.cond.L.Unlock() - } - - job, ok := d.heap.Pop() - if !ok { - continue - } - - d.priorityQueueChannel <- job - } -} - -// worker processes jobs from the priority queue channel. -// It executes each job and sends the result back through the job's ResultCh. -func (d *Dispatcher) worker(ctx context.Context, id uint) { - for { - select { - case job := <-d.priorityQueueChannel: - start := time.Now() - - err := job.Execute(ctx, d, id) - - select { - case job.ResultCh <- err: - case <-ctx.Done(): - return - } - - time.Sleep(time.Second - time.Since(start)) - - case <-ctx.Done(): - return - } - } -} - // DeleteJob removes a job from the heap by its ID. // Returns true if the job was found and deleted, false otherwise. // -// Example of usage: +// Example usage: // -// deleted := dispatcher.DeleteJob(jobID) +// deleted := dispatcher.DeleteJob(jobID) // // if !deleted { // // Handle job not found @@ -108,15 +68,15 @@ func (d *Dispatcher) DeleteJob(id uint64) bool { // DeleteJobFunc removes jobs from the heap that satisfy the given condition. // -// Example of usage: +// Example usage: // // deletedIDs := dispatcher.DeleteJobFunc(func(job MessageJob) bool { -// return job.Priority < 10 -// }) +// return job.Priority < 10 +// }) // -// for _, id := range deletedIDs { -// // Handle deleted job ID -// } +// for _, id := range deletedIDs { +// // Handle deleted job ID +// } func (d *Dispatcher) DeleteJobFunc(deleteFunc func(MessageJob) bool) []uint64 { return d.heap.DeleteFunc(deleteFunc) } @@ -124,7 +84,7 @@ func (d *Dispatcher) DeleteJobFunc(deleteFunc func(MessageJob) bool) []uint64 { // AddRawJob adds a raw job to the dispatcher with the specified request, priority, and task count. // It returns the job ID and a channel to receive the job result. // -// Example of usage: +// Example usage: // // jobID, resultCh := dispatcher.AddRawJob(request, priority, taskCount) // @@ -157,7 +117,7 @@ func (d *Dispatcher) AddRawJob( // AddEmptyJob adds the specified number of placeholder jobs to the dispatcher. // -// Example of usage: +// Example usage: // // dispatcher.AddEmptyJob(5) // Adds 5 placeholder jobs func (d *Dispatcher) AddEmptyJob(count uint) { @@ -168,11 +128,11 @@ func (d *Dispatcher) AddEmptyJob(count uint) { } } -// AddMessagesForfard adds a message forwarding job to the dispatcher. +// AddForwardMessagesJob adds a message forwarding job to the dispatcher. // -// Example of usage: +// Example usage: // -// jobID, resultCh := dispatcher.AddMessagesForward(messagesForwardMessagesRequest, priority) +// jobID, resultCh := dispatcher.AddForwardMessagesJob(messagesForwardMessagesRequest, priority) // // // Wait for the job result // result := <-resultCh @@ -180,7 +140,7 @@ func (d *Dispatcher) AddEmptyJob(count uint) { // if result.Err != nil { // // Handle job error // } -func (d *Dispatcher) AddMessagesForward( +func (d *Dispatcher) AddForwardMessagesJob( req *tg.MessagesForwardMessagesRequest, priority uint16, ) (uint64, <-chan JobResult) { @@ -192,11 +152,11 @@ func (d *Dispatcher) AddMessagesForward( return d.AddRawJob(req, priority, uint(len(req.RandomID))) } -// SendMessage adds a message sending job to the dispatcher. +// AddSendMessageJob adds a message sending job to the dispatcher. // -// Example of usage: +// Example usage: // -// jobID, resultCh := dispatcher.SendMessage(messagesSendMessageRequest, priority) +// jobID, resultCh := dispatcher.AddSendMessageJob(messagesSendMessageRequest, priority) // // // Wait for the job result // result := <-resultCh @@ -204,7 +164,7 @@ func (d *Dispatcher) AddMessagesForward( // if result.Err != nil { // // Handle job error // } -func (d *Dispatcher) SendMessage( +func (d *Dispatcher) AddSendMessageJob( req *tg.MessagesSendMessageRequest, priority uint16, ) (uint64, <-chan JobResult) { @@ -215,11 +175,11 @@ func (d *Dispatcher) SendMessage( return d.AddRawJob(req, priority, SingleMessage) } -// SendMedia adds a media sending job to the dispatcher. +// AddSendMultiMediaJob adds a media sending job to the dispatcher. // -// Example of usage: +// Example usage: // -// jobID, resultCh := dispatcher.SendMedia(messagesSendMediaRequest, priority) +// jobID, resultCh := dispatcher.AddSendMultiMediaJob(messagesSendMediaRequest, priority) // // // Wait for the job result // result := <-resultCh @@ -227,7 +187,7 @@ func (d *Dispatcher) SendMessage( // if result.Err != nil { // // Handle job error // } -func (d *Dispatcher) SendMultiMedia( +func (d *Dispatcher) AddSendMultiMediaJob( req *tg.MessagesSendMultiMediaRequest, priority uint16, ) (uint64, <-chan JobResult) { @@ -237,3 +197,57 @@ func (d *Dispatcher) SendMultiMedia( return d.AddRawJob(req, priority, uint(len(req.MultiMedia))) } + +func (d *Dispatcher) AddSendMediaJob( + req *tg.MessagesSendMediaRequest, + priority uint16, +) (uint64, <-chan JobResult) { + if req.RandomID == 0 { + req.RandomID = rand.Int63() + } + + return d.AddRawJob(req, priority, SingleMessage) +} + +// proccessMessagesQueue continuously processes jobs from the heap and sends them to the priority queue channel. +// It waits for new jobs if the heap is empty. +func (d *Dispatcher) proccessMessagesQueue() { + for { + if d.heap.Len() == 0 { + d.cond.L.Lock() + d.cond.Wait() + d.cond.L.Unlock() + } + + job, ok := d.heap.Pop() + if !ok { + continue + } + + d.messageJobChannel <- job + } +} + +// worker processes jobs from the priority queue channel. +// It executes each job and sends the result back through the job's ResultCh. +func (d *Dispatcher) worker(ctx context.Context, id uint) { + for { + select { + case job := <-d.messageJobChannel: + start := time.Now() + + err := job.Execute(ctx, d, id) + + select { + case job.ResultCh <- err: + case <-ctx.Done(): + return + } + + time.Sleep(time.Second - time.Since(start)) + + case <-ctx.Done(): + return + } + } +} diff --git a/yatgbot/router/middlewares.go b/yatgbot/middlewares.go similarity index 74% rename from yatgbot/router/middlewares.go rename to yatgbot/middlewares.go index 852fc0f..dbe7a7a 100644 --- a/yatgbot/router/middlewares.go +++ b/yatgbot/middlewares.go @@ -1,14 +1,17 @@ -package router +package yatgbot import ( "context" + "net/http" "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" "github.com/gotd/td/tg" ) +var ErrRouteMismatch = yaerrors.FromString(http.StatusContinue, "route: handler type mismatch") + // HandlerNext is a function that represents the next handler in the middleware chain. -type HandlerNext func(ctx context.Context, handlerData *HandlerData, upd tg.UpdateClass) yaerrors.Error +type HandlerNext = func(ctx context.Context, handlerData *HandlerData, upd tg.UpdateClass) yaerrors.Error // HandlerMiddleware is a middleware function that can process an update before or after the main handler. type HandlerMiddleware func( @@ -20,15 +23,19 @@ type HandlerMiddleware func( // AddMiddleware adds one or more middlewares to the router. // -// Example of usage: +// Example usage: // // r.AddMiddleware(loggingMiddleware, authMiddleware) -func (r *Router) AddMiddleware(mw ...HandlerMiddleware) { +func (r *RouterGroup) AddMiddleware(mw ...HandlerMiddleware) { r.middlewares = append(r.middlewares, mw...) } // chainMiddleware chains the provided middlewares and returns a single HandlerNext function. func chainMiddleware(final HandlerNext, middlewares ...HandlerMiddleware) HandlerNext { + if len(middlewares) == 0 { + return final + } + for _, mw := range middlewares { middleware := mw next := final @@ -46,16 +53,17 @@ func wrapHandler[T tg.UpdateClass]( h func(context.Context, *HandlerData, T) yaerrors.Error, ) HandlerNext { return func(ctx context.Context, handlerData *HandlerData, upd tg.UpdateClass) yaerrors.Error { - if t, ok := upd.(T); ok { - return h(ctx, handlerData, t) + t, ok := upd.(T) + if !ok { + return ErrRouteMismatch } - return nil + return h(ctx, handlerData, t) } } // collectMiddlewares collects middlewares from the current router and its parent routers. -func (r *Router) collectMiddlewares() []HandlerMiddleware { +func (r *RouterGroup) collectMiddlewares() []HandlerMiddleware { if r.parent == nil { return r.middlewares } diff --git a/yatgbot/router.go b/yatgbot/router.go new file mode 100644 index 0000000..ced316a --- /dev/null +++ b/yatgbot/router.go @@ -0,0 +1,184 @@ +package yatgbot + +import ( + "context" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" + "github.com/YaCodeDev/GoYaCodeDevUtils/yafsm" + "github.com/YaCodeDev/GoYaCodeDevUtils/yalocales" + "github.com/YaCodeDev/GoYaCodeDevUtils/yalogger" + "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/messagequeue" + "github.com/YaCodeDev/GoYaCodeDevUtils/yatgclient" + "github.com/gotd/td/tg" +) + +// HandlerData holds the dependencies and context for a handler execution. +type HandlerData struct { + Entities tg.Entities + Client *yatgclient.Client + Update tg.UpdateClass + UserID int64 + Peer tg.InputPeerClass + StateStorage *yafsm.EntityFSMStorage + Log yalogger.Logger + Dispatcher *messagequeue.Dispatcher + Localizer yalocales.Localizer + JobResults []messagequeue.JobResult +} + +type ( + // CallbackHandler is a function that processes incoming callback queries. + CallbackHandler func(ctx context.Context, handlerData *HandlerData, cb *tg.UpdateBotCallbackQuery) yaerrors.Error + + // NewMessageHandler is a function that processes incoming messages. + NewMessageHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateNewMessage) yaerrors.Error + + EditMessageHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateEditMessage) yaerrors.Error + + DeleteMessageHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateDeleteMessages) yaerrors.Error + + NewChannelMessageHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateNewChannelMessage) yaerrors.Error + + EditChannelMessageHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateEditChannelMessage) yaerrors.Error + + DeleteChannelMessagesHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateDeleteChannelMessages) yaerrors.Error + + MessageReactionsHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateMessageReactions) yaerrors.Error + + ChannelParticipantHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateChannelParticipant) yaerrors.Error + + // PrecheckoutQueryHandler is a function that processes incoming pre-checkout queries. + PrecheckoutQueryHandler func(ctx context.Context, handlerData *HandlerData, query *tg.UpdateBotPrecheckoutQuery) yaerrors.Error + + InlineQueryHandler func(ctx context.Context, handlerData *HandlerData, query *tg.UpdateBotInlineQuery) yaerrors.Error +) + +// RouterGroup is the main struct that holds routes, sub-routers, and middlewares. +type RouterGroup struct { + parent *RouterGroup + base []Filter + sub []*RouterGroup + routes []route + middlewares []HandlerMiddleware +} + +// Dependencies holds the external dependencies required by the Router. +// route represents a single route in the router. +type route struct { + filters []Filter + handler HandlerNext +} + +// NewRouterGroup creates a new Router instance with the given name. +// +// Example usage: +// +// r := router.NewRouterGroup("main", YourDependencies) +func NewRouterGroup() *RouterGroup { + return &RouterGroup{} +} + +// IncludeRouter includes sub-routers into the current router. +// It sets the parent and inherits dependencies if they are not set. +// +// Example usage: +// +// subRouter := New("sub", nil) +// +// mainRouter := New("main", YourDependencies) +// +// mainRouter.IncludeRouter(subRouter) +func (r *RouterGroup) IncludeRouter(subs ...*RouterGroup) { + for _, s := range subs { + s.parent = r + + r.sub = append(r.sub, s) + } +} + +// OnCallback registers a callback handler with optional filters. +// +// Example usage: +// +// router.OnCallback(YourCallbackHandler, YourFilter1, YourFilter2) +func (r *RouterGroup) OnCallback(h CallbackHandler, filters ...Filter) { + r.routes = append(r.routes, route{ + handler: wrapHandler(h), + filters: filters, + }) +} + +// OnMessage registers a message handler with optional filters. +// +// Example usage: +// +// router.OnMessage(YourMessageHandler, YourFilter1, YourFilter2) +func (r *RouterGroup) OnMessage(h NewMessageHandler, filters ...Filter) { + r.routes = append(r.routes, route{ + handler: wrapHandler(h), + filters: filters, + }) +} + +func (r *RouterGroup) OnEditMessage(h EditMessageHandler, filters ...Filter) { + r.routes = append(r.routes, route{ + handler: wrapHandler(h), + filters: filters, + }) +} + +func (r *RouterGroup) OnDeleteMessage(h DeleteMessageHandler, filters ...Filter) { + r.routes = append(r.routes, route{ + handler: wrapHandler(h), + filters: filters, + }) +} + +func (r *RouterGroup) OnNewChannelMessage(h NewChannelMessageHandler, filters ...Filter) { + r.routes = append(r.routes, route{ + handler: wrapHandler(h), + filters: filters, + }) +} + +func (r *RouterGroup) OnEditChannelMessage(h EditChannelMessageHandler, filters ...Filter) { + r.routes = append(r.routes, route{ + handler: wrapHandler(h), + filters: filters, + }) +} + +func (r *RouterGroup) OnDeleteChannelMessages(h DeleteChannelMessagesHandler, filters ...Filter) { + r.routes = append(r.routes, route{ + handler: wrapHandler(h), + filters: filters, + }) +} + +func (r *RouterGroup) OnMessageReactions(h MessageReactionsHandler, filters ...Filter) { + r.routes = append(r.routes, route{ + handler: wrapHandler(h), + filters: filters, + }) +} + +func (r *RouterGroup) OnChannelParticipant(h ChannelParticipantHandler, filters ...Filter) { + r.routes = append(r.routes, route{ + handler: wrapHandler(h), + filters: filters, + }) +} + +func (r *RouterGroup) OnPrecheckoutQuery(h PrecheckoutQueryHandler, filters ...Filter) { + r.routes = append(r.routes, route{ + handler: wrapHandler(h), + filters: filters, + }) +} + +func (r *RouterGroup) OnInlineQuery(h InlineQueryHandler, filters ...Filter) { + r.routes = append(r.routes, route{ + handler: wrapHandler(h), + filters: filters, + }) +} diff --git a/yatgbot/router/router.go b/yatgbot/router/router.go deleted file mode 100644 index b33cbb7..0000000 --- a/yatgbot/router/router.go +++ /dev/null @@ -1,134 +0,0 @@ -package router - -import ( - "context" - - "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" - "github.com/YaCodeDev/GoYaCodeDevUtils/yafsm" - "github.com/YaCodeDev/GoYaCodeDevUtils/yalocales" - "github.com/YaCodeDev/GoYaCodeDevUtils/yalogger" - "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/messagequeue" - "github.com/gotd/td/telegram/message" - "github.com/gotd/td/tg" -) - -// MessageHandler is a function that processes incoming messages. -// CallbackHandler is a function that processes incoming callback queries. -type ( - MessageHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateNewMessage) yaerrors.Error - CallbackHandler func(ctx context.Context, handlerData *HandlerData, cb *tg.UpdateBotCallbackQuery) yaerrors.Error -) - -// route represents a single route in the router. -type route struct { - filters []Filter - msgHandler MessageHandler - cbHandler CallbackHandler -} - -// Router is the main struct that holds routes, sub-routers, and middlewares. -type Router struct { - Dependencies - - parent *Router - name string - base []Filter - sub []*Router - routes []*route - middlewares []HandlerMiddleware -} - -// Dependencies holds the external dependencies required by the Router. -type Dependencies struct { - FSMStore yafsm.FSM - Log yalogger.Logger - MessageDispatcher *messagequeue.Dispatcher - Localizer yalocales.Localizer - Client *tg.Client - Sender *message.Sender -} - -// New creates a new Router instance with the given name and dependencies. -// If deps is nil, it initializes with default zero values. -// -// Example of usage: -// -// r := router.New("main", YourDependencies) -func New(name string, deps *Dependencies) *Router { - if deps == nil { - deps = &Dependencies{} - } - - r := &Router{ - name: name, - Dependencies: *deps, - } - - return r -} - -// IncludeRouter includes sub-routers into the current router. -// It sets the parent and inherits dependencies if they are not set. -// -// Example of usage: -// -// subRouter := New("sub", nil) -// -// mainRouter := New("main", YourDependencies) -// -// mainRouter.IncludeRouter(subRouter) -func (r *Router) IncludeRouter(subs ...*Router) { - for _, s := range subs { - s.parent = r - - if s.Sender == nil { - s.Sender = r.Sender - } - - if s.FSMStore == nil { - s.FSMStore = r.FSMStore - } - - if s.Log == nil { - s.Log = r.Log - } - - if s.MessageDispatcher == nil { - s.MessageDispatcher = r.MessageDispatcher - } - - if s.Localizer == nil { - s.Localizer = r.Localizer - } - - if s.Client == nil { - s.Client = r.Client - } - - r.sub = append(r.sub, s) - } -} - -// OnMessage registers a message handler with optional filters. -// -// Example of usage: -// -// router.OnMessage(YourMessageHandler, YourFilter1, YourFilter2) -func (r *Router) OnMessage(h MessageHandler, filters ...Filter) { - r.routes = append(r.routes, &route{ - msgHandler: h, - filters: filters, - }) -} - -// OnCallback registers a callback handler with optional filters. -// -// Example of usage: -// -// router.OnCallback(YourCallbackHandler, YourFilter1, YourFilter2) -func (r *Router) OnCallback(h CallbackHandler, filters ...Filter) { - r.routes = append(r.routes, &route{ - cbHandler: h, - filters: filters, - }) -} diff --git a/yatgbot/router/schema.go b/yatgbot/router/schema.go deleted file mode 100644 index b8fe2a6..0000000 --- a/yatgbot/router/schema.go +++ /dev/null @@ -1,24 +0,0 @@ -package router - -import ( - "github.com/YaCodeDev/GoYaCodeDevUtils/yafsm" - "github.com/YaCodeDev/GoYaCodeDevUtils/yalocales" - "github.com/YaCodeDev/GoYaCodeDevUtils/yalogger" - "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/messagequeue" - "github.com/gotd/td/telegram/message" - "github.com/gotd/td/tg" -) - -// HandlerData holds the dependencies and context for a handler execution. -type HandlerData struct { - Entities tg.Entities - Sender *message.Sender - Client *tg.Client - Update tg.UpdateClass - UserID int64 - Peer tg.InputPeerClass - StateStorage *yafsm.EntityFSMStorage - Log yalogger.Logger - Dispatcher *messagequeue.Dispatcher - Localizer yalocales.Localizer -} diff --git a/yatgbot/router/updates.go b/yatgbot/router/updates.go deleted file mode 100644 index 6633408..0000000 --- a/yatgbot/router/updates.go +++ /dev/null @@ -1,65 +0,0 @@ -package router - -import ( - "context" - - "github.com/gotd/td/tg" -) - -// Bind binds the router to the given update dispatcher. -// It sets up updates handling for bot. -// It should be called once during the bot setup. -// After calling this method, the router will start receiving updates -// and dispatching them to the appropriate handlers based on the defined routes and filters. -// -// Example of usage: -// -// // dispatcher := tg.NewUpdateDispatcher(yourClient) -// -// r := router.New("main", YourDependencies) -// r.Bind(dispatcher) -func (r *Router) Bind(d *tg.UpdateDispatcher) { - d.OnNewMessage(r.wrapMessage) - d.OnBotCallbackQuery(r.wrapCallback) -} - -// wrapMessage wraps the message handler to match the expected signature for the update dispatcher. -func (r *Router) wrapMessage(ctx context.Context, ent tg.Entities, upd *tg.UpdateNewMessage) error { - msg, ok := upd.Message.(*tg.Message) - if !ok { - return nil - } - - uid, _ := getUserID(msg.PeerID, msg.FromID) - - chatID, _ := getChatID(msg.PeerID, ent) - - peer, _ := makeInputPeer(msg.PeerID, ent) - - return r.dispatch(ctx, DispatcherDependencies{ - userID: uid, - chatID: chatID, - ent: ent, - update: upd, - inputPeer: peer, - }) -} - -// wrapCallback wraps the callback query handler to match the expected signature for the update dispatcher. -func (r *Router) wrapCallback( - ctx context.Context, - ent tg.Entities, - q *tg.UpdateBotCallbackQuery, -) error { - chatID, _ := getChatID(q.Peer, ent) - - peer, _ := makeInputPeer(q.Peer, ent) - - return r.dispatch(ctx, DispatcherDependencies{ - userID: q.UserID, - chatID: chatID, - ent: ent, - update: q, - inputPeer: peer, - }) -} diff --git a/yatgbot/router/utils.go b/yatgbot/router/utils.go deleted file mode 100644 index ebdad52..0000000 --- a/yatgbot/router/utils.go +++ /dev/null @@ -1,28 +0,0 @@ -package router - -import "github.com/gotd/td/tg" - -// extractMessageFromUpdate tries to extract a *tg.Message from the given update. -// It returns the message and true if successful, otherwise nil and false. -// -// Example of usage: -// -// msg, ok := extractMessageFromUpdate(update) -// -// if ok { -// // process msg -// } -func extractMessageFromUpdate(upd tg.UpdateClass) (*tg.Message, bool) { - switch u := upd.(type) { - case *tg.UpdateNewMessage: - if msg, ok := u.Message.(*tg.Message); ok { - return msg, true - } - case *tg.UpdateNewChannelMessage: - if msg, ok := u.Message.(*tg.Message); ok { - return msg, true - } - } - - return nil, false -} diff --git a/yatgbot/updates.go b/yatgbot/updates.go new file mode 100644 index 0000000..85752dd --- /dev/null +++ b/yatgbot/updates.go @@ -0,0 +1,332 @@ +package yatgbot + +import ( + "context" + + "github.com/gotd/td/tg" +) + +// Bind binds the router to the given update dispatcher. +// It sets up updates handling for bot. +// It should be called once during the bot setup. +// After calling this method, the router will start receiving updates +// and dispatching them to the appropriate handlers based on the defined routes and filters. +// +// Example usage: +// +// // dispatcher := tg.NewUpdateDispatcher(yourClient) +// +// r := router.New("main", YourDependencies) +// r.Bind(dispatcher) +func (r *Dispatcher) Bind(tgDispatcher *tg.UpdateDispatcher) { + tgDispatcher.OnNewMessage(r.handleNewMessage) + tgDispatcher.OnBotCallbackQuery(r.handleBotCallbackQuery) + tgDispatcher.OnDeleteMessages(r.handleDeleteMessages) + tgDispatcher.OnEditMessage(r.handleEditMessage) + tgDispatcher.OnNewChannelMessage(r.handleNewChannelMessage) + tgDispatcher.OnEditChannelMessage(r.handleEditChannelMessage) + tgDispatcher.OnChannelParticipant(r.handleChannelParticipant) + tgDispatcher.OnDeleteChannelMessages(r.handleDeleteChannelMessages) + tgDispatcher.OnBotMessageReactions(r.handleBotMessageReactions) + tgDispatcher.OnBotPrecheckoutQuery(r.handleBotPrecheckoutQuery) + tgDispatcher.OnBotInlineQuery(r.handleBotInlineQuery) +} + +// handleNewMessage wraps the message handler to match the expected signature for the update dispatcher. +func (r *Dispatcher) handleNewMessage(ctx context.Context, ent tg.Entities, upd *tg.UpdateNewMessage) error { + var uid int64 + var chatID int64 + var peer tg.InputPeerClass + + switch msg := upd.Message.(type) { + case *tg.Message: + if msg.FromID != nil { + if fromUser, ok := msg.FromID.(*tg.PeerUser); ok { + if fromUser.UserID == r.BotUser.ID { + return nil + } + } + } + + uid, _ = getUserID(msg.PeerID, msg.FromID) + + chatID, _ = getChatID(msg.PeerID, ent) + + peer, _ = makeInputPeer(msg.PeerID, ent) + + case *tg.MessageService: + uid, _ = getUserID(msg.PeerID, msg.FromID) + + chatID, _ = getChatID(msg.PeerID, ent) + + peer, _ = makeInputPeer(msg.PeerID, ent) + default: + return nil + } + + return r.dispatch(ctx, UpdateData{ + userID: uid, + chatID: chatID, + ent: ent, + update: upd, + inputPeer: peer, + }) +} + +// handleBotCallbackQuery wraps the callback query handler to match the expected signature for the update dispatcher. +func (r *Dispatcher) handleBotCallbackQuery( + ctx context.Context, + ent tg.Entities, + q *tg.UpdateBotCallbackQuery, +) error { + chatID, _ := getChatID(q.Peer, ent) + + peer, _ := makeInputPeer(q.Peer, ent) + + return r.dispatch(ctx, UpdateData{ + userID: q.UserID, + chatID: chatID, + ent: ent, + update: q, + inputPeer: peer, + }) +} + +func (r *Dispatcher) handleNewChannelMessage( + ctx context.Context, + ent tg.Entities, + upd *tg.UpdateNewChannelMessage, +) error { + var uid int64 + var chatID int64 + var peer tg.InputPeerClass + + switch msg := upd.Message.(type) { + case *tg.Message: + if msg.FromID != nil { + if fromUser, ok := msg.FromID.(*tg.PeerUser); ok { + if fromUser.UserID == r.BotUser.ID { + return nil + } + } + } + + uid, _ = getUserID(msg.PeerID, msg.FromID) + + chatID, _ = getChatID(msg.PeerID, ent) + + peer, _ = makeInputPeer(msg.PeerID, ent) + + case *tg.MessageService: + uid, _ = getUserID(msg.PeerID, msg.FromID) + + chatID, _ = getChatID(msg.PeerID, ent) + + peer, _ = makeInputPeer(msg.PeerID, ent) + default: + return nil + } + + return r.dispatch(ctx, UpdateData{ + userID: uid, + chatID: chatID, + ent: ent, + update: upd, + inputPeer: peer, + }) +} + +func (r *Dispatcher) handleBotPrecheckoutQuery( + ctx context.Context, + ent tg.Entities, + upd *tg.UpdateBotPrecheckoutQuery, +) error { + user, ok := ent.Users[upd.UserID] + if !ok { + return nil + } + + var chatID int64 + var inputPeer tg.InputPeerClass + + if len(ent.Chats) > 0 { + chatID = ent.Chats[0].ID + inputPeer = &tg.InputPeerChat{ + ChatID: chatID, + } + } else { + chatID = upd.UserID + inputPeer = &tg.InputPeerUser{ + UserID: upd.UserID, + AccessHash: user.AccessHash, + } + } + + return r.dispatch(ctx, UpdateData{ + userID: upd.UserID, + chatID: chatID, + ent: ent, + update: upd, + inputPeer: inputPeer, + }) +} + +func (r *Dispatcher) handleEditMessage( + ctx context.Context, + ent tg.Entities, + upd *tg.UpdateEditMessage, +) error { + r.Log.Infof("EditMessage: %+v", upd) + msg, ok := upd.Message.(*tg.Message) + if !ok { + return nil + } + + if msg.FromID != nil { + if fromUser, ok := msg.FromID.(*tg.PeerUser); ok { + if fromUser.UserID == r.BotUser.ID { + return nil + } + } + } + + invoice, ok := msg.Media.(*tg.MessageMediaInvoice) + + if ok { + r.Log.Infof("Invoice received: %+v", invoice) + } + + uid, _ := getUserID(msg.PeerID, msg.FromID) + + chatID, _ := getChatID(msg.PeerID, ent) + + peer, _ := makeInputPeer(msg.PeerID, ent) + + return r.dispatch(ctx, UpdateData{ + userID: uid, + chatID: chatID, + ent: ent, + update: upd, + inputPeer: peer, + }) +} + +func (r *Dispatcher) handleBotInlineQuery( + ctx context.Context, + ent tg.Entities, + upd *tg.UpdateBotInlineQuery, +) error { + user, ok := ent.Users[upd.UserID] + if !ok { + return nil + } + + var chatID int64 + var inputPeer tg.InputPeerClass + + if len(ent.Chats) > 0 { + chatID = ent.Chats[0].ID + inputPeer = &tg.InputPeerChat{ + ChatID: chatID, + } + } else { + chatID = upd.UserID + inputPeer = &tg.InputPeerUser{ + UserID: upd.UserID, + AccessHash: user.AccessHash, + } + } + + return r.dispatch(ctx, UpdateData{ + userID: upd.UserID, + chatID: chatID, + ent: ent, + update: upd, + inputPeer: inputPeer, + }) +} + +func (r *Dispatcher) handleEditChannelMessage( + ctx context.Context, + ent tg.Entities, + upd *tg.UpdateEditChannelMessage, +) error { + msg, ok := upd.Message.(*tg.Message) + if !ok { + return nil + } + + uid, _ := getUserID(msg.PeerID, msg.FromID) + + chatID, _ := getChatID(msg.PeerID, ent) + + peer, _ := makeInputPeer(msg.PeerID, ent) + + return r.dispatch(ctx, UpdateData{ + userID: uid, + chatID: chatID, + ent: ent, + update: upd, + inputPeer: peer, + }) +} + +func (r *Dispatcher) handleDeleteMessages( + ctx context.Context, + ent tg.Entities, + upd *tg.UpdateDeleteMessages, +) error { + return r.dispatch(ctx, UpdateData{ + userID: 0, + chatID: 0, + ent: ent, + update: upd, + inputPeer: nil, + }) +} + +func (r *Dispatcher) handleDeleteChannelMessages( + ctx context.Context, + ent tg.Entities, + upd *tg.UpdateDeleteChannelMessages, +) error { + return r.dispatch(ctx, UpdateData{ + userID: 0, + chatID: upd.ChannelID, + ent: ent, + update: upd, + inputPeer: nil, + }) +} + +func (r *Dispatcher) handleChannelParticipant( + ctx context.Context, + ent tg.Entities, + upd *tg.UpdateChannelParticipant, +) error { + return r.dispatch(ctx, UpdateData{ + userID: upd.UserID, + chatID: upd.ChannelID, + ent: ent, + update: upd, + inputPeer: nil, + }) +} + +func (r *Dispatcher) handleBotMessageReactions( + ctx context.Context, + ent tg.Entities, + upd *tg.UpdateBotMessageReactions, +) error { + chatID, _ := getChatID(upd.Peer, ent) + + peer, _ := makeInputPeer(upd.Peer, ent) + + return r.dispatch(ctx, UpdateData{ + userID: 0, + chatID: chatID, + ent: ent, + update: upd, + inputPeer: peer, + }) +} diff --git a/yatgbot/utils.go b/yatgbot/utils.go new file mode 100644 index 0000000..09c10ab --- /dev/null +++ b/yatgbot/utils.go @@ -0,0 +1,43 @@ +package yatgbot + +import "github.com/gotd/td/tg" + +// ExtractMessageFromUpdate tries to extract a *tg.Message from the given update. +// It returns the message and true if successful, otherwise nil and false. +// +// Example usage: +// +// msg, ok := ExtractMessageFromUpdate(update) +// +// if ok { +// // process msg +// } +func ExtractMessageFromUpdate(upd tg.UpdateClass) (*tg.Message, bool) { + switch u := upd.(type) { + case *tg.UpdateNewMessage: + if msg, ok := u.Message.(*tg.Message); ok { + return msg, true + } + case *tg.UpdateNewChannelMessage: + if msg, ok := u.Message.(*tg.Message); ok { + return msg, true + } + } + + return nil, false +} + +func ExtractMessageServiceFromUpdate(upd tg.UpdateClass) (*tg.MessageService, bool) { + switch u := upd.(type) { + case *tg.UpdateNewMessage: + if msg, ok := u.Message.(*tg.MessageService); ok { + return msg, true + } + case *tg.UpdateNewChannelMessage: + if msg, ok := u.Message.(*tg.MessageService); ok { + return msg, true + } + } + + return nil, false +} diff --git a/yatgbot/yatgbot.go b/yatgbot/yatgbot.go new file mode 100644 index 0000000..4f93b0b --- /dev/null +++ b/yatgbot/yatgbot.go @@ -0,0 +1,119 @@ +package yatgbot + +import ( + "context" + "io/fs" + "net/http" + "strconv" + "strings" + + "github.com/YaCodeDev/GoYaCodeDevUtils/yacache" + "github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors" + "github.com/YaCodeDev/GoYaCodeDevUtils/yafsm" + "github.com/YaCodeDev/GoYaCodeDevUtils/yalocales" + "github.com/YaCodeDev/GoYaCodeDevUtils/yalogger" + "github.com/YaCodeDev/GoYaCodeDevUtils/yatgbot/messagequeue" + "github.com/YaCodeDev/GoYaCodeDevUtils/yatgclient" + "github.com/YaCodeDev/GoYaCodeDevUtils/yatgstorage" + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/updates" + "github.com/gotd/td/tg" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +func InitYaTgBot( + ctx context.Context, + defaultLang string, + appID int, + appHash string, + botToken string, + poolDB *gorm.DB, + messageQueueRatePerSecond uint, + embeddedLocales fs.FS, + log yalogger.Logger, + cache yacache.Cache[*redis.Client], + mainRouter *RouterGroup, +) (Dispatcher, yaerrors.Error) { + head, _, _ := strings.Cut(botToken, ":") + + BotID, err := strconv.ParseInt(strings.TrimSpace(head), 10, 64) + if err != nil || BotID <= 0 { + return Dispatcher{}, yaerrors.FromError( + http.StatusBadRequest, + err, + "invalid bot token provided", + ) + } + + telegramDispatcher := tg.NewUpdateDispatcher() + + fsmStorage := yafsm.NewDefaultFSMStorage(cache, yafsm.EmptyState{}) + + localizer := yalocales.NewLocalizer(defaultLang, true) + if yaErr := localizer.LoadLocales(embeddedLocales); yaErr != nil { + return Dispatcher{}, yaErr + } + + gormSessionRepo, yaErr := yatgstorage.NewGormSessionStorage(poolDB) + if yaErr != nil { + return Dispatcher{}, yaErr + } + + sessionStorage := yatgstorage.NewSessionStorageWithCustomRepo(BotID, botToken, gormSessionRepo) + stateStorage := yatgstorage.NewStorage(cache, log) + + gaps := yatgclient.NewUpdateManagerWithYaStorage( + BotID, + telegramDispatcher, + stateStorage, + ) + + client := yatgclient.NewClient( + yatgclient.ClientOptions{ + AppID: appID, + AppHash: appHash, + EntityID: BotID, + TelegramOptions: telegram.Options{ + SessionStorage: sessionStorage.TelegramSessionStorageCompatible(), + UpdateHandler: gaps, + }, + }, + log, + ) + + msgDispatcher := messagequeue.NewDispatcher(ctx, client, messageQueueRatePerSecond, log) + + if err := client.BackgroundConnect(ctx); err != nil { + return Dispatcher{}, err + } + + if err := client.BotAuthorization(ctx, botToken); err != nil { + return Dispatcher{}, err + } + + _ = client.RunUpdatesManager(ctx, gaps, updates.AuthOptions{IsBot: true}, nil) + + botUser, err := client.Self(ctx) + if err != nil { + return Dispatcher{}, yaerrors.FromError( + http.StatusInternalServerError, + err, + "failed to get bot user", + ) + } + + dispatcher := Dispatcher{ + FSMStore: fsmStorage, + Log: log, + BotUser: botUser, + MessageDispatcher: msgDispatcher, + Localizer: localizer, + Client: client, + MainRouter: mainRouter, + } + + dispatcher.Bind(&telegramDispatcher) + + return dispatcher, nil +} From 3c6fa58aa44c14dfd0a815cdf9d9411362f64530 Mon Sep 17 00:00:00 2001 From: Olderestin Date: Thu, 4 Dec 2025 05:06:57 +0200 Subject: [PATCH 13/18] fix(yatgbot): `messagequeqe` sorting by timestamp --- yatgbot/messagequeue/heap.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yatgbot/messagequeue/heap.go b/yatgbot/messagequeue/heap.go index d1aba94..0dd4890 100644 --- a/yatgbot/messagequeue/heap.go +++ b/yatgbot/messagequeue/heap.go @@ -116,9 +116,9 @@ func (h *messageHeap) sort() { switch { case a.Timestamp.Before(b.Timestamp): - return 1 - case a.Timestamp.After(b.Timestamp): return -1 + case a.Timestamp.After(b.Timestamp): + return 1 default: return 0 } From 2d5549fa26c818829aca4074d55a1977ff1f9678 Mon Sep 17 00:00:00 2001 From: Olderestin Date: Thu, 4 Dec 2025 05:11:21 +0200 Subject: [PATCH 14/18] style(yatgbot): Improve code formatting and naming --- yatgbot/dispatcher.go | 16 ++++- yatgbot/filter.go | 2 + yatgbot/messagequeue/messagequeue.go | 4 +- yatgbot/router.go | 90 ++++++++++++++++++++++------ yatgbot/updates.go | 36 +++++++---- 5 files changed, 113 insertions(+), 35 deletions(-) diff --git a/yatgbot/dispatcher.go b/yatgbot/dispatcher.go index 9d2ff09..5410a96 100644 --- a/yatgbot/dispatcher.go +++ b/yatgbot/dispatcher.go @@ -54,7 +54,12 @@ func (r *Dispatcher) dispatch(ctx context.Context, deps UpdateData) yaerrors.Err }, rt.filters) if err != nil { - return yaerrors.FromErrorWithLog(http.StatusInternalServerError, err, "failed to apply filters", r.Log) + return yaerrors.FromErrorWithLog( + http.StatusInternalServerError, + err, + "failed to apply filters", + r.Log, + ) } if !ok { @@ -95,11 +100,18 @@ func (r *Dispatcher) dispatch(ctx context.Context, deps UpdateData) yaerrors.Err Client: r.Client, } - err = chainMiddleware(rt.handler, r.MainRouter.collectMiddlewares()...)(ctx, hdata, deps.update) + err = chainMiddleware( + rt.handler, + r.MainRouter.collectMiddlewares()...)( + ctx, + hdata, + deps.update, + ) if err != nil { if errors.Is(err, ErrRouteMismatch) { continue } + return err.Wrap("handler execution failed") } diff --git a/yatgbot/filter.go b/yatgbot/filter.go index 264a5ce..30abc79 100644 --- a/yatgbot/filter.go +++ b/yatgbot/filter.go @@ -113,6 +113,7 @@ func MessageServiceActionFilter[T tg.MessageActionClass]() Filter { return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { if messageService, ok := ExtractMessageServiceFromUpdate(deps.update); ok { _, ok := messageService.Action.(T) + return ok, nil } @@ -123,6 +124,7 @@ func MessageServiceActionFilter[T tg.MessageActionClass]() Filter { func MessageServiceFilter() Filter { return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { _, ok := ExtractMessageServiceFromUpdate(deps.update) + return ok, nil } } diff --git a/yatgbot/messagequeue/messagequeue.go b/yatgbot/messagequeue/messagequeue.go index 8846c98..a2ebe57 100644 --- a/yatgbot/messagequeue/messagequeue.go +++ b/yatgbot/messagequeue/messagequeue.go @@ -236,10 +236,10 @@ func (d *Dispatcher) worker(ctx context.Context, id uint) { case job := <-d.messageJobChannel: start := time.Now() - err := job.Execute(ctx, d, id) + jobResult := job.Execute(ctx, d, id) select { - case job.ResultCh <- err: + case job.ResultCh <- jobResult: case <-ctx.Done(): return } diff --git a/yatgbot/router.go b/yatgbot/router.go index ced316a..c4c6711 100644 --- a/yatgbot/router.go +++ b/yatgbot/router.go @@ -28,29 +28,81 @@ type HandlerData struct { type ( // CallbackHandler is a function that processes incoming callback queries. - CallbackHandler func(ctx context.Context, handlerData *HandlerData, cb *tg.UpdateBotCallbackQuery) yaerrors.Error + CallbackHandler func( + ctx context.Context, + handlerData *HandlerData, + cb *tg.UpdateBotCallbackQuery, + ) yaerrors.Error // NewMessageHandler is a function that processes incoming messages. - NewMessageHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateNewMessage) yaerrors.Error - - EditMessageHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateEditMessage) yaerrors.Error - - DeleteMessageHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateDeleteMessages) yaerrors.Error - - NewChannelMessageHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateNewChannelMessage) yaerrors.Error - - EditChannelMessageHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateEditChannelMessage) yaerrors.Error - - DeleteChannelMessagesHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateDeleteChannelMessages) yaerrors.Error - - MessageReactionsHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateMessageReactions) yaerrors.Error - - ChannelParticipantHandler func(ctx context.Context, handlerData *HandlerData, msg *tg.UpdateChannelParticipant) yaerrors.Error + NewMessageHandler func( + ctx context.Context, + handlerData *HandlerData, + msg *tg.UpdateNewMessage, + ) yaerrors.Error + + // EditMessageHandler is a function that processes edited messages. + EditMessageHandler func( + ctx context.Context, + handlerData *HandlerData, + msg *tg.UpdateEditMessage, + ) yaerrors.Error + + // DeleteMessageHandler is a function that processes deleted messages. + DeleteMessageHandler func( + ctx context.Context, + handlerData *HandlerData, + msg *tg.UpdateDeleteMessages, + ) yaerrors.Error + + // NewChannelMessageHandler is a function that processes new channel messages. + NewChannelMessageHandler func( + ctx context.Context, + handlerData *HandlerData, + msg *tg.UpdateNewChannelMessage, + ) yaerrors.Error + + // EditChannelMessageHandler is a function that processes edited channel messages. + EditChannelMessageHandler func( + ctx context.Context, + handlerData *HandlerData, + msg *tg.UpdateEditChannelMessage, + ) yaerrors.Error + + // DeleteChannelMessagesHandler is a function that processes deleted channel messages. + DeleteChannelMessagesHandler func( + ctx context.Context, + handlerData *HandlerData, + msg *tg.UpdateDeleteChannelMessages, + ) yaerrors.Error + + // MessageReactionsHandler is a function that processes message reactions updates. + MessageReactionsHandler func( + ctx context.Context, + handlerData *HandlerData, + msg *tg.UpdateMessageReactions, + ) yaerrors.Error + + // ChannelParticipantHandler is a function that processes channel participant updates. + ChannelParticipantHandler func( + ctx context.Context, + handlerData *HandlerData, + msg *tg.UpdateChannelParticipant, + ) yaerrors.Error // PrecheckoutQueryHandler is a function that processes incoming pre-checkout queries. - PrecheckoutQueryHandler func(ctx context.Context, handlerData *HandlerData, query *tg.UpdateBotPrecheckoutQuery) yaerrors.Error - - InlineQueryHandler func(ctx context.Context, handlerData *HandlerData, query *tg.UpdateBotInlineQuery) yaerrors.Error + PrecheckoutQueryHandler func( + tx context.Context, + handlerData *HandlerData, + query *tg.UpdateBotPrecheckoutQuery, + ) yaerrors.Error + + // InlineQueryHandler is a function that processes incoming inline queries. + InlineQueryHandler func( + ctx context.Context, + handlerData *HandlerData, + query *tg.UpdateBotInlineQuery, + ) yaerrors.Error ) // RouterGroup is the main struct that holds routes, sub-routers, and middlewares. diff --git a/yatgbot/updates.go b/yatgbot/updates.go index 85752dd..43c7905 100644 --- a/yatgbot/updates.go +++ b/yatgbot/updates.go @@ -33,10 +33,16 @@ func (r *Dispatcher) Bind(tgDispatcher *tg.UpdateDispatcher) { } // handleNewMessage wraps the message handler to match the expected signature for the update dispatcher. -func (r *Dispatcher) handleNewMessage(ctx context.Context, ent tg.Entities, upd *tg.UpdateNewMessage) error { - var uid int64 - var chatID int64 - var peer tg.InputPeerClass +func (r *Dispatcher) handleNewMessage( + ctx context.Context, + ent tg.Entities, + upd *tg.UpdateNewMessage, +) error { + var ( + uid int64 + chatID int64 + peer tg.InputPeerClass + ) switch msg := upd.Message.(type) { case *tg.Message: @@ -97,9 +103,11 @@ func (r *Dispatcher) handleNewChannelMessage( ent tg.Entities, upd *tg.UpdateNewChannelMessage, ) error { - var uid int64 - var chatID int64 - var peer tg.InputPeerClass + var ( + uid int64 + chatID int64 + peer tg.InputPeerClass + ) switch msg := upd.Message.(type) { case *tg.Message: @@ -146,8 +154,10 @@ func (r *Dispatcher) handleBotPrecheckoutQuery( return nil } - var chatID int64 - var inputPeer tg.InputPeerClass + var ( + chatID int64 + inputPeer tg.InputPeerClass + ) if len(ent.Chats) > 0 { chatID = ent.Chats[0].ID @@ -183,7 +193,7 @@ func (r *Dispatcher) handleEditMessage( } if msg.FromID != nil { - if fromUser, ok := msg.FromID.(*tg.PeerUser); ok { + if fromUser, okPeer := msg.FromID.(*tg.PeerUser); okPeer { if fromUser.UserID == r.BotUser.ID { return nil } @@ -221,8 +231,10 @@ func (r *Dispatcher) handleBotInlineQuery( return nil } - var chatID int64 - var inputPeer tg.InputPeerClass + var ( + chatID int64 + inputPeer tg.InputPeerClass + ) if len(ent.Chats) > 0 { chatID = ent.Chats[0].ID From b778c10cdf7c21df94c8b466e614a90d0b0f23ab Mon Sep 17 00:00:00 2001 From: Olderestin Date: Thu, 4 Dec 2025 05:14:04 +0200 Subject: [PATCH 15/18] chore(yatgbot): Update docstrings --- yatgbot/dispatcher.go | 1 + yatgbot/filter.go | 22 +++++++++++++++++ yatgbot/middlewares.go | 2 +- yatgbot/router.go | 54 +++++++++++++++++++++++++++++++++++++----- yatgbot/updates.go | 25 +++++++++++++++---- yatgbot/utils.go | 10 ++++++++ yatgbot/yatgbot.go | 23 ++++++++++++++++++ 7 files changed, 126 insertions(+), 11 deletions(-) diff --git a/yatgbot/dispatcher.go b/yatgbot/dispatcher.go index 5410a96..0ea0677 100644 --- a/yatgbot/dispatcher.go +++ b/yatgbot/dispatcher.go @@ -15,6 +15,7 @@ import ( "github.com/gotd/td/tg" ) +// Dispatcher is responsible for routing updates to the appropriate handlers based on defined routes and filters. type Dispatcher struct { FSMStore yafsm.FSM Log yalogger.Logger diff --git a/yatgbot/filter.go b/yatgbot/filter.go index 30abc79..760c47f 100644 --- a/yatgbot/filter.go +++ b/yatgbot/filter.go @@ -97,6 +97,7 @@ func CallbackEq(data string) Filter { // CallbackPrefix creates a filter that checks if the callback query data starts with the specified prefix. // // Example usage: +// // router.OnCallback(YourCallbackHandler, router.CallbackPrefix("prefix_")) func CallbackPrefix(prefix string) Filter { return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { @@ -109,6 +110,12 @@ func CallbackPrefix(prefix string) Filter { } } +// MessageServiceActionFilter creates a filter that checks if the message service action +// matches the specified type T. +// +// Example usage: +// +// router.OnMessageService(YourMessageServiceHandler, router.MessageServiceActionFilter[*tg.MessageActionChatCreate]()) func MessageServiceActionFilter[T tg.MessageActionClass]() Filter { return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { if messageService, ok := ExtractMessageServiceFromUpdate(deps.update); ok { @@ -121,6 +128,11 @@ func MessageServiceActionFilter[T tg.MessageActionClass]() Filter { } } +// MessageServiceFilter creates a filter that checks if the update contains a MessageService. +// +// Example usage: +// +// router.OnMessageService(YourMessageServiceHandler, router.MessageServiceFilter()) func MessageServiceFilter() Filter { return func(_ context.Context, deps FilterDependencies) (bool, yaerrors.Error) { _, ok := ExtractMessageServiceFromUpdate(deps.update) @@ -129,6 +141,11 @@ func MessageServiceFilter() Filter { } } +// OneOfFilter creates a filter that passes if any of the provided filters pass. +// +// Example usage: +// +// router.OnMessage(YourMessageHandler, router.OneOfFilter(filter1, filter2)) func OneOfFilter(filters ...Filter) Filter { return func(ctx context.Context, deps FilterDependencies) (bool, yaerrors.Error) { for _, f := range filters { @@ -146,6 +163,11 @@ func OneOfFilter(filters ...Filter) Filter { } } +// AllOfFilter creates a filter that passes only if all of the provided filters pass. +// +// Example usage: +// +// router.OnMessage(YourMessageHandler, router.AllOfFilter(filter1, filter2)) func AllOfFilter(filters ...Filter) Filter { return func(ctx context.Context, deps FilterDependencies) (bool, yaerrors.Error) { for _, f := range filters { diff --git a/yatgbot/middlewares.go b/yatgbot/middlewares.go index dbe7a7a..397ab1c 100644 --- a/yatgbot/middlewares.go +++ b/yatgbot/middlewares.go @@ -48,7 +48,7 @@ func chainMiddleware(final HandlerNext, middlewares ...HandlerMiddleware) Handle return final } -// wrapHandler wraps a specific handler function to match the HandlerNext signature. +// wrapHandler wraps a specific handler function into a generic HandlerNext function. func wrapHandler[T tg.UpdateClass]( h func(context.Context, *HandlerData, T) yaerrors.Error, ) HandlerNext { diff --git a/yatgbot/router.go b/yatgbot/router.go index c4c6711..0826793 100644 --- a/yatgbot/router.go +++ b/yatgbot/router.go @@ -114,8 +114,7 @@ type RouterGroup struct { middlewares []HandlerMiddleware } -// Dependencies holds the external dependencies required by the Router. -// route represents a single route in the router. +// route represents a single route with its associated filters and handler. type route struct { filters []Filter handler HandlerNext @@ -135,11 +134,9 @@ func NewRouterGroup() *RouterGroup { // // Example usage: // -// subRouter := New("sub", nil) +// subRouter := router.NewRouterGroup() // -// mainRouter := New("main", YourDependencies) -// -// mainRouter.IncludeRouter(subRouter) +// router.IncludeRouter(subRouter) func (r *RouterGroup) IncludeRouter(subs ...*RouterGroup) { for _, s := range subs { s.parent = r @@ -172,6 +169,11 @@ func (r *RouterGroup) OnMessage(h NewMessageHandler, filters ...Filter) { }) } +// OnEditMessage registers an edit message handler with optional filters. +// +// Example usage: +// +// router.OnEditMessage(YourEditMessageHandler, YourFilter1, YourFilter2) func (r *RouterGroup) OnEditMessage(h EditMessageHandler, filters ...Filter) { r.routes = append(r.routes, route{ handler: wrapHandler(h), @@ -179,6 +181,11 @@ func (r *RouterGroup) OnEditMessage(h EditMessageHandler, filters ...Filter) { }) } +// OnDeleteMessage registers a delete message handler with optional filters. +// +// Example usage: +// +// router.OnDeleteMessage(YourDeleteMessageHandler, YourFilter1, YourFilter2) func (r *RouterGroup) OnDeleteMessage(h DeleteMessageHandler, filters ...Filter) { r.routes = append(r.routes, route{ handler: wrapHandler(h), @@ -186,6 +193,11 @@ func (r *RouterGroup) OnDeleteMessage(h DeleteMessageHandler, filters ...Filter) }) } +// OnNewChannelMessage registers a new channel message handler with optional filters. +// +// Example usage: +// +// router.OnNewChannelMessage(YourNewChannelMessageHandler, YourFilter1, YourFilter2) func (r *RouterGroup) OnNewChannelMessage(h NewChannelMessageHandler, filters ...Filter) { r.routes = append(r.routes, route{ handler: wrapHandler(h), @@ -193,6 +205,11 @@ func (r *RouterGroup) OnNewChannelMessage(h NewChannelMessageHandler, filters .. }) } +// OnEditChannelMessage registers an edit channel message handler with optional filters. +// +// Example usage: +// +// router.OnEditChannelMessage(YourEditChannelMessageHandler, YourFilter1, YourFilter2) func (r *RouterGroup) OnEditChannelMessage(h EditChannelMessageHandler, filters ...Filter) { r.routes = append(r.routes, route{ handler: wrapHandler(h), @@ -200,6 +217,11 @@ func (r *RouterGroup) OnEditChannelMessage(h EditChannelMessageHandler, filters }) } +// OnDeleteChannelMessages registers a delete channel messages handler with optional filters. +// +// Example usage: +// +// router.OnDeleteChannelMessages(YourDeleteChannelMessagesHandler, YourFilter1, YourFilter2) func (r *RouterGroup) OnDeleteChannelMessages(h DeleteChannelMessagesHandler, filters ...Filter) { r.routes = append(r.routes, route{ handler: wrapHandler(h), @@ -207,6 +229,11 @@ func (r *RouterGroup) OnDeleteChannelMessages(h DeleteChannelMessagesHandler, fi }) } +// OnMessageReactions registers a message reactions handler with optional filters. +// +// Example usage: +// +// router.OnMessageReactions(YourMessageReactionsHandler, YourFilter1, YourFilter2) func (r *RouterGroup) OnMessageReactions(h MessageReactionsHandler, filters ...Filter) { r.routes = append(r.routes, route{ handler: wrapHandler(h), @@ -214,6 +241,11 @@ func (r *RouterGroup) OnMessageReactions(h MessageReactionsHandler, filters ...F }) } +// OnChannelParticipant registers a channel participant handler with optional filters. +// +// Example usage: +// +// router.OnChannelParticipant(YourChannelParticipantHandler, YourFilter1, YourFilter2) func (r *RouterGroup) OnChannelParticipant(h ChannelParticipantHandler, filters ...Filter) { r.routes = append(r.routes, route{ handler: wrapHandler(h), @@ -221,6 +253,11 @@ func (r *RouterGroup) OnChannelParticipant(h ChannelParticipantHandler, filters }) } +// OnPrecheckoutQuery registers a pre-checkout query handler with optional filters. +// +// Example usage: +// +// router.OnPrecheckoutQuery(YourPrecheckoutQueryHandler, YourFilter1, YourFilter2) func (r *RouterGroup) OnPrecheckoutQuery(h PrecheckoutQueryHandler, filters ...Filter) { r.routes = append(r.routes, route{ handler: wrapHandler(h), @@ -228,6 +265,11 @@ func (r *RouterGroup) OnPrecheckoutQuery(h PrecheckoutQueryHandler, filters ...F }) } +// OnInlineQuery registers an inline query handler with optional filters. +// +// Example usage: +// +// router.OnInlineQuery(YourInlineQueryHandler, YourFilter1, YourFilter2) func (r *RouterGroup) OnInlineQuery(h InlineQueryHandler, filters ...Filter) { r.routes = append(r.routes, route{ handler: wrapHandler(h), diff --git a/yatgbot/updates.go b/yatgbot/updates.go index 43c7905..587433c 100644 --- a/yatgbot/updates.go +++ b/yatgbot/updates.go @@ -14,10 +14,11 @@ import ( // // Example usage: // -// // dispatcher := tg.NewUpdateDispatcher(yourClient) +// router := yatgbot.NewRouterGroup() // -// r := router.New("main", YourDependencies) -// r.Bind(dispatcher) +// dispatcher := tg.NewUpdateDispatcher(yourClient) +// +// router.Bind(dispatcher) func (r *Dispatcher) Bind(tgDispatcher *tg.UpdateDispatcher) { tgDispatcher.OnNewMessage(r.handleNewMessage) tgDispatcher.OnBotCallbackQuery(r.handleBotCallbackQuery) @@ -32,7 +33,7 @@ func (r *Dispatcher) Bind(tgDispatcher *tg.UpdateDispatcher) { tgDispatcher.OnBotInlineQuery(r.handleBotInlineQuery) } -// handleNewMessage wraps the message handler to match the expected signature for the update dispatcher. +// handleNewMessage wraps the new message handler to match the expected signature for the update dispatcher. func (r *Dispatcher) handleNewMessage( ctx context.Context, ent tg.Entities, @@ -98,6 +99,8 @@ func (r *Dispatcher) handleBotCallbackQuery( }) } +// handleNewChannelMessage wraps the new channel message handler to match +// the expected signature for the update dispatcher. func (r *Dispatcher) handleNewChannelMessage( ctx context.Context, ent tg.Entities, @@ -144,6 +147,8 @@ func (r *Dispatcher) handleNewChannelMessage( }) } +// handleBotPrecheckoutQuery wraps the pre-checkout query handler to match +// the expected signature for the update dispatcher. func (r *Dispatcher) handleBotPrecheckoutQuery( ctx context.Context, ent tg.Entities, @@ -181,12 +186,14 @@ func (r *Dispatcher) handleBotPrecheckoutQuery( }) } +// handleEditMessage wraps the edit message handler to match the expected signature for the update dispatcher. func (r *Dispatcher) handleEditMessage( ctx context.Context, ent tg.Entities, upd *tg.UpdateEditMessage, ) error { r.Log.Infof("EditMessage: %+v", upd) + msg, ok := upd.Message.(*tg.Message) if !ok { return nil @@ -221,6 +228,7 @@ func (r *Dispatcher) handleEditMessage( }) } +// handleBotInlineQuery wraps the inline query handler to match the expected signature for the update dispatcher. func (r *Dispatcher) handleBotInlineQuery( ctx context.Context, ent tg.Entities, @@ -258,6 +266,8 @@ func (r *Dispatcher) handleBotInlineQuery( }) } +// handleEditChannelMessage wraps the edit channel message handler to match +// the expected signature for the update dispatcher. func (r *Dispatcher) handleEditChannelMessage( ctx context.Context, ent tg.Entities, @@ -283,6 +293,7 @@ func (r *Dispatcher) handleEditChannelMessage( }) } +// handleDeleteMessages wraps the delete messages handler to match the expected signature for the update dispatcher. func (r *Dispatcher) handleDeleteMessages( ctx context.Context, ent tg.Entities, @@ -297,6 +308,8 @@ func (r *Dispatcher) handleDeleteMessages( }) } +// handleDeleteChannelMessages wraps the delete channel messages handler to match +// the expected signature for the update dispatcher. func (r *Dispatcher) handleDeleteChannelMessages( ctx context.Context, ent tg.Entities, @@ -311,6 +324,8 @@ func (r *Dispatcher) handleDeleteChannelMessages( }) } +// handleChannelParticipant wraps the channel participant handler to match +// the expected signature for the update dispatcher. func (r *Dispatcher) handleChannelParticipant( ctx context.Context, ent tg.Entities, @@ -325,6 +340,8 @@ func (r *Dispatcher) handleChannelParticipant( }) } +// handleBotMessageReactions wraps the message reactions handler to match +// the expected signature for the update dispatcher. func (r *Dispatcher) handleBotMessageReactions( ctx context.Context, ent tg.Entities, diff --git a/yatgbot/utils.go b/yatgbot/utils.go index 09c10ab..9d926b1 100644 --- a/yatgbot/utils.go +++ b/yatgbot/utils.go @@ -27,6 +27,16 @@ func ExtractMessageFromUpdate(upd tg.UpdateClass) (*tg.Message, bool) { return nil, false } +// ExtractMessageServiceFromUpdate tries to extract a *tg.MessageService from the given update. +// It returns the message service and true if successful, otherwise nil and false. +// +// Example usage: +// +// msgService, ok := ExtractMessageServiceFromUpdate(update) +// +// if ok { +// // process msgService +// } func ExtractMessageServiceFromUpdate(upd tg.UpdateClass) (*tg.MessageService, bool) { switch u := upd.(type) { case *tg.UpdateNewMessage: diff --git a/yatgbot/yatgbot.go b/yatgbot/yatgbot.go index 4f93b0b..a18d44e 100644 --- a/yatgbot/yatgbot.go +++ b/yatgbot/yatgbot.go @@ -22,6 +22,29 @@ import ( "gorm.io/gorm" ) +// InitYaTgBot initializes and returns a Dispatcher for the Telegram bot. +// It sets up the necessary components such as the Telegram client, session storage, +// FSM storage, localizer, and message dispatcher. +// +// Example usage: +// +// dispatcher, err := InitYaTgBot( +// ctx, +// "en", +// appID, +// appHash, +// botToken, +// poolDB, +// 10, +// embeddedLocales, +// log, +// cache, +// mainRouter, +// ) +// +// If err != nil { +// // Handle error +// } func InitYaTgBot( ctx context.Context, defaultLang string, From a8357d7a161eb87bb8b8b92d9cfa855543666219 Mon Sep 17 00:00:00 2001 From: Olderestin Date: Thu, 4 Dec 2025 05:19:47 +0200 Subject: [PATCH 16/18] refactor(yatgbot): Remove unecessary check --- yatgbot/updates.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/yatgbot/updates.go b/yatgbot/updates.go index 587433c..a378f66 100644 --- a/yatgbot/updates.go +++ b/yatgbot/updates.go @@ -114,14 +114,6 @@ func (r *Dispatcher) handleNewChannelMessage( switch msg := upd.Message.(type) { case *tg.Message: - if msg.FromID != nil { - if fromUser, ok := msg.FromID.(*tg.PeerUser); ok { - if fromUser.UserID == r.BotUser.ID { - return nil - } - } - } - uid, _ = getUserID(msg.PeerID, msg.FromID) chatID, _ = getChatID(msg.PeerID, ent) From cc67fccc7a81d5b4c56fd724463740b2bdf29a67 Mon Sep 17 00:00:00 2001 From: Olderestin Date: Fri, 5 Dec 2025 06:20:46 +0200 Subject: [PATCH 17/18] chore(yatgbot): Ignore `unexported-return` for `fsm` --- yafsm/entityfsm.go | 2 +- yafsm/fsm.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/yafsm/entityfsm.go b/yafsm/entityfsm.go index 65a7f9e..b387bd7 100644 --- a/yafsm/entityfsm.go +++ b/yafsm/entityfsm.go @@ -54,7 +54,7 @@ func (b *EntityFSMStorage) SetState( // } func (b *EntityFSMStorage) GetState( ctx context.Context, -) (string, stateDataMarshalled, yaerrors.Error) { +) (string, stateDataMarshalled, yaerrors.Error) { // nolint: revive return b.storage.GetState(ctx, b.uid) } diff --git a/yafsm/fsm.go b/yafsm/fsm.go index 881e464..8bcd15a 100644 --- a/yafsm/fsm.go +++ b/yafsm/fsm.go @@ -118,7 +118,7 @@ func (b *DefaultFSMStorage[T]) SetState( func (b *DefaultFSMStorage[T]) GetState( ctx context.Context, uid string, -) (string, stateDataMarshalled, yaerrors.Error) { +) (string, stateDataMarshalled, yaerrors.Error) { // nolint: revive data, err := b.storage.Get(ctx, uid) if err != nil { return b.defaultState.StateName(), "", nil From c0fab37432bd8f43a7eb75c3e1170f45a3d385c2 Mon Sep 17 00:00:00 2001 From: Olderestin Date: Tue, 9 Dec 2025 00:24:38 +0200 Subject: [PATCH 18/18] fix(yatgbot): `messagequeqe` sorting by timestamp --- yatgbot/messagequeue/heap.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yatgbot/messagequeue/heap.go b/yatgbot/messagequeue/heap.go index 0dd4890..d1aba94 100644 --- a/yatgbot/messagequeue/heap.go +++ b/yatgbot/messagequeue/heap.go @@ -116,9 +116,9 @@ func (h *messageHeap) sort() { switch { case a.Timestamp.Before(b.Timestamp): - return -1 - case a.Timestamp.After(b.Timestamp): return 1 + case a.Timestamp.After(b.Timestamp): + return -1 default: return 0 }