Files
go-telegram/dispatch/groups.go
T
lukaszraczylo 0ee539e991 perf(dispatch): typed Context.Command/CommandArgs/RegexMatch fields
Move the three conventional Values keys ("command", "command_args", "regex_match") to typed fields on Context. Router and group routing write the fields directly; the Values map is allocated lazily via the new Set method and reserved for user-defined custom keys.

Allocation impact (M4 Max, b.Loop()):

  DispatchCommand:   5 allocs/op -> 1, 153ns -> 69ns (-55%)

  DispatchTextRegex: 5 allocs/op -> 2, 181ns -> 107ns (-41%)

  DispatchFilter:    2 allocs/op -> 1, 32ns -> 19ns (-41%)

  NewContext:        5.79ns -> 1.60ns

Trade-off: Context struct grew from ~48B to ~96B (three new fields), so filter-only paths pay ~50B more per dispatch. Command/regex paths save ~320B + 4 allocs each, which dominates for typical bot workloads.

Handlers reading c.Values["command"], c.Values["command_args"], or c.Values["regex_match"] now get nil; the typed fields c.Command, c.CommandArgs, c.RegexMatch are the new accessors. Custom keys still work via c.Set(k, v) and c.Values[k].
2026-05-10 02:35:24 +01:00

187 lines
5.4 KiB
Go

package dispatch
import (
"errors"
"regexp"
"sort"
"github.com/lukaszraczylo/go-telegram/api"
)
// ErrEndGroups stops dispatch from running any further handlers in any
// group for this update when returned by a handler. Use it to indicate
// the update has been definitively handled.
//
// errors.Is(err, ErrEndGroups) is the canonical check, though dispatch
// itself recognises it by exact identity.
var ErrEndGroups = errors.New("dispatch: end groups")
// ErrContinueGroups signals that this group's handler should be treated
// as not-matching when returned by a handler: dispatch moves on to the
// next handler in the same group, then to subsequent groups.
//
// Without ErrContinueGroups, a non-error return from a matched handler
// stops dispatch (default first-match-wins semantics).
var ErrContinueGroups = errors.New("dispatch: continue groups")
// RouterScope registers handlers into a specific priority group on its parent
// Router. Group 0 runs first, then group 1, etc. Within a group, handlers run
// in registration order; the first non-skipped match terminates dispatch
// unless the handler returns ErrContinueGroups.
type RouterScope struct {
router *Router
group int
}
// Group returns a RouterScope that registers handlers in the given group.
// Group 0 (the default) runs first, then group 1, etc. Within a group,
// handlers run in registration order; the first non-skipped match
// terminates dispatch unless the handler returns ErrContinueGroups.
func (r *Router) Group(group int) *RouterScope {
return &RouterScope{router: r, group: group}
}
// OnCommand registers a command handler in this group.
func (s *RouterScope) OnCommand(cmd string, h Handler[*api.Message]) {
s.router.groupCommands = append(s.router.groupCommands, groupCommandRoute{
cmd: cmd, group: s.group, handler: h,
})
}
// OnText registers a regex text handler in this group.
// Panics at registration time if pattern is not a valid regular expression.
func (s *RouterScope) OnText(pattern string, h Handler[*api.Message]) {
s.router.groupTexts = append(s.router.groupTexts, groupTextRoute{
re: regexp.MustCompile(pattern), group: s.group, handler: h,
})
}
// OnMessageFilter registers a filter-based message handler in this group.
func (s *RouterScope) OnMessageFilter(f Filter[*api.Message], h Handler[*api.Message]) {
s.router.groupMessageFilters = append(s.router.groupMessageFilters, groupMessageFilterRoute{
filter: f, group: s.group, handler: h,
})
}
// group-aware route types
type groupCommandRoute struct {
cmd string
group int
handler Handler[*api.Message]
}
type groupTextRoute struct {
re *regexp.Regexp
group int
handler Handler[*api.Message]
}
type groupMessageFilterRoute struct {
filter Filter[*api.Message]
group int
handler Handler[*api.Message]
}
// dispatchGroups runs message handlers registered via RouterScope.Group().
// It collects all matching groups, sorts by group number, and applies
// first-match-wins semantics within each group. Handlers may return
// ErrContinueGroups (skip to next handler/group) or ErrEndGroups (stop all groups).
// A non-sentinel error stops dispatch and is returned to the caller.
func (r *Router) dispatchGroups(c *Context, m *api.Message) error {
// Collect group numbers present.
groupSet := map[int]struct{}{}
for _, gr := range r.groupCommands {
groupSet[gr.group] = struct{}{}
}
for _, gr := range r.groupTexts {
groupSet[gr.group] = struct{}{}
}
for _, gr := range r.groupMessageFilters {
groupSet[gr.group] = struct{}{}
}
if len(groupSet) == 0 {
return nil
}
groups := make([]int, 0, len(groupSet))
for g := range groupSet {
groups = append(groups, g)
}
sort.Ints(groups)
for _, g := range groups {
matched, err := r.runGroupHandlers(c, m, g)
if err != nil {
if errors.Is(err, ErrEndGroups) {
return nil
}
return err
}
if matched {
// First-match-wins: stop further groups.
return nil
}
// No match or ErrContinueGroups from all handlers: try next group.
}
return nil
}
// runGroupHandlers runs all handlers in group g against m, in registration
// order. Returns (true, nil) when a handler matched (returned nil). Returns
// (false, nil) when all handlers returned ErrContinueGroups. Returns
// (false, err) for ErrEndGroups or any non-sentinel error.
func (r *Router) runGroupHandlers(c *Context, m *api.Message, g int) (matched bool, err error) {
// Commands.
if cmd, args, ok := extractCommand(m); ok {
for _, route := range r.groupCommands {
if route.group != g || route.cmd != cmd {
continue
}
c.Command = cmd
c.CommandArgs = args
if err := route.handler(c, m); err != nil {
if errors.Is(err, ErrContinueGroups) {
continue
}
return false, err
}
return true, nil
}
}
// Text regex.
if m.Text != "" {
for _, route := range r.groupTexts {
if route.group != g {
continue
}
subs := route.re.FindStringSubmatch(m.Text)
if subs == nil {
continue
}
c.RegexMatch = subs
if err := route.handler(c, m); err != nil {
if errors.Is(err, ErrContinueGroups) {
continue
}
return false, err
}
return true, nil
}
}
// Filter-based.
for _, route := range r.groupMessageFilters {
if route.group != g || !route.filter(m) {
continue
}
if err := route.handler(c, m); err != nil {
if errors.Is(err, ErrContinueGroups) {
continue
}
return false, err
}
return true, nil
}
return false, nil
}