Skip to content

Commit 085bea2

Browse files
ernadoclaude
andcommitted
feat(handler): add handler framework core (router, middleware, context)
Handler, Context (embeds the request context plus Bot and Update), Predicate and Middleware types, and a concurrency-safe router on Bot. Bot.Use registers global middleware (outermost-first); route dispatches to the first matching handler wrapped in middleware and contains handler errors by logging them. The dispatcher wiring and On* registration come next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5d049bb commit 085bea2

3 files changed

Lines changed: 152 additions & 0 deletions

File tree

bot.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ type Bot struct {
3030
gaps *updates.Manager
3131
disp tg.UpdateDispatcher
3232

33+
router router
34+
3335
onStart func(ctx context.Context)
3436

3537
self *tg.User

handler.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"sync"
6+
7+
"go.uber.org/zap"
8+
)
9+
10+
// Context is passed to a Handler. It embeds the request context (so it can be
11+
// passed straight to Bot methods) and carries the Bot and the Update.
12+
type Context struct {
13+
context.Context
14+
15+
Bot *Bot
16+
Update *Update
17+
}
18+
19+
// Handler processes a single update.
20+
type Handler func(c *Context) error
21+
22+
// Predicate reports whether a Handler should run for an update. A Handler runs
23+
// only if all of its predicates return true.
24+
type Predicate func(u *Update) bool
25+
26+
// Middleware wraps a Handler, returning a new one. Middleware registered with
27+
// Bot.Use runs for every handled update, outermost first.
28+
type Middleware func(next Handler) Handler
29+
30+
type route struct {
31+
handler Handler
32+
predicates []Predicate
33+
}
34+
35+
func (r route) matches(u *Update) bool {
36+
for _, p := range r.predicates {
37+
if !p(u) {
38+
return false
39+
}
40+
}
41+
return true
42+
}
43+
44+
// router holds the registered routes and global middleware. The zero value is
45+
// ready to use and safe for concurrent registration and routing.
46+
type router struct {
47+
mu sync.RWMutex
48+
routes []route
49+
mws []Middleware
50+
}
51+
52+
// Use registers global middleware applied to every handled update. Middleware
53+
// runs outermost-first in registration order. Call before Run.
54+
func (b *Bot) Use(mws ...Middleware) {
55+
b.router.mu.Lock()
56+
defer b.router.mu.Unlock()
57+
b.router.mws = append(b.router.mws, mws...)
58+
}
59+
60+
// on registers a handler guarded by the given predicates.
61+
func (b *Bot) on(handler Handler, predicates ...Predicate) {
62+
b.router.mu.Lock()
63+
defer b.router.mu.Unlock()
64+
b.router.routes = append(b.router.routes, route{handler: handler, predicates: predicates})
65+
}
66+
67+
// route dispatches an update to the first matching handler, wrapped in the
68+
// global middleware. Handler errors are logged, not propagated to the update
69+
// loop, so one failing handler does not tear down the bot.
70+
func (b *Bot) route(ctx context.Context, u *Update) {
71+
b.router.mu.RLock()
72+
routes := b.router.routes
73+
mws := b.router.mws
74+
b.router.mu.RUnlock()
75+
76+
for _, r := range routes {
77+
if !r.matches(u) {
78+
continue
79+
}
80+
h := r.handler
81+
for i := len(mws) - 1; i >= 0; i-- {
82+
h = mws[i](h)
83+
}
84+
c := &Context{Context: ctx, Bot: b, Update: u}
85+
if err := h(c); err != nil {
86+
b.log.Error("Handler error", zap.Error(err))
87+
}
88+
return
89+
}
90+
}

handler_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
)
8+
9+
func TestRouterFirstMatchWins(t *testing.T) {
10+
b := newTestBot(t)
11+
var calls []string
12+
b.on(func(c *Context) error { calls = append(calls, "skipped"); return nil }, func(u *Update) bool { return false })
13+
b.on(func(c *Context) error { calls = append(calls, "matched"); return nil })
14+
b.on(func(c *Context) error { calls = append(calls, "second-match"); return nil })
15+
16+
b.route(context.Background(), &Update{UpdateID: 1})
17+
18+
if len(calls) != 1 || calls[0] != "matched" {
19+
t.Fatalf("expected only the first matching handler, got %v", calls)
20+
}
21+
}
22+
23+
func TestMiddlewareOrder(t *testing.T) {
24+
b := newTestBot(t)
25+
var order []string
26+
b.Use(func(next Handler) Handler {
27+
return func(c *Context) error {
28+
order = append(order, "outer-in")
29+
defer func() { order = append(order, "outer-out") }()
30+
return next(c)
31+
}
32+
})
33+
b.Use(func(next Handler) Handler {
34+
return func(c *Context) error {
35+
order = append(order, "inner-in")
36+
defer func() { order = append(order, "inner-out") }()
37+
return next(c)
38+
}
39+
})
40+
b.on(func(c *Context) error { order = append(order, "handler"); return nil })
41+
42+
b.route(context.Background(), &Update{})
43+
44+
want := []string{"outer-in", "inner-in", "handler", "inner-out", "outer-out"}
45+
if len(order) != len(want) {
46+
t.Fatalf("order = %v, want %v", order, want)
47+
}
48+
for i := range want {
49+
if order[i] != want[i] {
50+
t.Fatalf("order = %v, want %v", order, want)
51+
}
52+
}
53+
}
54+
55+
func TestRouterHandlerErrorIsContained(t *testing.T) {
56+
b := newTestBot(t)
57+
b.on(func(c *Context) error { return errors.New("boom") })
58+
// Must not panic or propagate; the error is logged.
59+
b.route(context.Background(), &Update{})
60+
}

0 commit comments

Comments
 (0)