Skip to content

Commit ec97c6a

Browse files
authored
Merge pull request #458 from fluffur/allow-predicates-access-context-and-add-pre-middlewares
feat(router)!: add UseOuter middleware layer and update Predicate signature
2 parents 1604b5e + 2e6a290 commit ec97c6a

11 files changed

Lines changed: 236 additions & 93 deletions

File tree

business_updates.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,10 @@ func (u *Update) businessConnectionID() string {
133133
}
134134

135135
// Kind predicates for business updates.
136-
func hasBusinessMessage(u *Update) bool { return u.BusinessMessage != nil }
137-
func hasEditedBusinessMessage(u *Update) bool { return u.EditedBusinessMessage != nil }
138-
func hasBusinessConnection(u *Update) bool { return u.BusinessConnection != nil }
139-
func hasDeletedBusinessMessages(u *Update) bool { return u.DeletedBusinessMessages != nil }
136+
func hasBusinessMessage(c *Context) bool { return c.Update.BusinessMessage != nil }
137+
func hasEditedBusinessMessage(c *Context) bool { return c.Update.EditedBusinessMessage != nil }
138+
func hasBusinessConnection(c *Context) bool { return c.Update.BusinessConnection != nil }
139+
func hasDeletedBusinessMessages(c *Context) bool { return c.Update.DeletedBusinessMessages != nil }
140140

141141
// OnBusinessMessage registers a handler for new messages from a connected
142142
// business account.

docs/guide.md

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,8 +251,8 @@ queue. (`PeerRef` is for sending; chat-management methods still take a resolved
251251

252252
## Predicates
253253

254-
Every `On*` method accepts trailing `Predicate`s (`func(*Update) bool`); the
255-
handler runs only when all match. First match wins across handlers.
254+
Every `On*` method accepts trailing `Predicate`s (`func(*botapi.Context) bool`);
255+
the handler runs only when all match. First match wins across handlers.
256256

257257
```go
258258
bot.OnMessage(handler, botapi.HasText(), botapi.Not(botapi.HasPrefix("/")))
@@ -263,8 +263,8 @@ Built-ins: `Command`, `HasPrefix`, `HasText`, `TextEquals`, `Regex`,
263263
Write your own — it's just a function:
264264

265265
```go
266-
func hasPhoto(u *botapi.Update) bool {
267-
m := u.EffectiveMessage()
266+
func hasPhoto(c *botapi.Context) bool {
267+
m := c.Message()
268268
return m != nil && len(m.Photo) > 0
269269
}
270270
```
@@ -280,6 +280,27 @@ bot.Use(botapi.Recover(), botapi.Timeout(30*time.Second), botapi.Logging())
280280

281281
Built-ins: `Recover` (turns panics into errors), `Timeout`, `Logging`.
282282

283+
## Outer Middleware
284+
285+
An `OuterMiddleware` is also a `func(Handler) Handler`. Register global middleware
286+
that runs before route matching with `UseOuter`:
287+
```go
288+
bot.UseOuter(botapi.Recover(), ChatConfigMiddleware())
289+
```
290+
291+
See [`examples/middleware`](../examples/middleware) for a complete example
292+
showing how data flows from outer middleware to predicates and handlers.
293+
294+
### Pipeline Execution Order
295+
296+
Middlewares and handlers execute in a distinct lifecycle layer (from the outside in).
297+
The visualization below demonstrates how an update travels through the bot:
298+
299+
1. **`UseOuter`** (Always runs first; can short-circuit or inject data into `Context`).
300+
2. **`Predicate` matching** (Evaluates route guards; has access to data from `UseOuter`).
301+
3. **`Use`** (Runs only if a route matches; ideal for logging, telemetry, or lazy-loading user sessions).
302+
4. **`Handler`** (Your core business logic).
303+
283304
## Groups
284305

285306
`Group` scopes shared predicates and middleware to a subset of handlers:

examples/advanced/main.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -435,28 +435,28 @@ func reverse(s string) string {
435435
return string(r)
436436
}
437437

438-
func hasPhoto(u *botapi.Update) bool {
439-
m := u.EffectiveMessage()
438+
func hasPhoto(c *botapi.Context) bool {
439+
m := c.Message()
440440
return m != nil && len(m.Photo) > 0
441441
}
442442

443-
func hasDocument(u *botapi.Update) bool {
444-
m := u.EffectiveMessage()
443+
func hasDocument(c *botapi.Context) bool {
444+
m := c.Message()
445445
return m != nil && m.Document != nil
446446
}
447447

448-
func hasSticker(u *botapi.Update) bool {
449-
m := u.EffectiveMessage()
448+
func hasSticker(c *botapi.Context) bool {
449+
m := c.Message()
450450
return m != nil && m.Sticker != nil
451451
}
452452

453-
func hasLocation(u *botapi.Update) bool {
454-
m := u.EffectiveMessage()
453+
func hasLocation(c *botapi.Context) bool {
454+
m := c.Message()
455455
return m != nil && m.Location != nil
456456
}
457457

458-
func hasContact(u *botapi.Update) bool {
459-
m := u.EffectiveMessage()
458+
func hasContact(c *botapi.Context) bool {
459+
m := c.Message()
460460
return m != nil && m.Contact != nil
461461
}
462462

examples/demo/helpers.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,28 +26,28 @@ func reverse(s string) string {
2626

2727
// --- incoming-media predicates, shared by media.go ---
2828

29-
func hasPhoto(u *botapi.Update) bool {
30-
m := u.EffectiveMessage()
29+
func hasPhoto(c *botapi.Context) bool {
30+
m := c.Message()
3131
return m != nil && len(m.Photo) > 0
3232
}
3333

34-
func hasDocument(u *botapi.Update) bool {
35-
m := u.EffectiveMessage()
34+
func hasDocument(c *botapi.Context) bool {
35+
m := c.Message()
3636
return m != nil && m.Document != nil
3737
}
3838

39-
func hasSticker(u *botapi.Update) bool {
40-
m := u.EffectiveMessage()
39+
func hasSticker(c *botapi.Context) bool {
40+
m := c.Message()
4141
return m != nil && m.Sticker != nil
4242
}
4343

44-
func hasLocation(u *botapi.Update) bool {
45-
m := u.EffectiveMessage()
44+
func hasLocation(c *botapi.Context) bool {
45+
m := c.Message()
4646
return m != nil && m.Location != nil
4747
}
4848

49-
func hasContact(u *botapi.Update) bool {
50-
m := u.EffectiveMessage()
49+
func hasContact(c *botapi.Context) bool {
50+
m := c.Message()
5151
return m != nil && m.Contact != nil
5252
}
5353

examples/media/main.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,13 @@ func main() {
116116
}
117117

118118
// hasPhoto matches messages that carry a photo.
119-
func hasPhoto(u *botapi.Update) bool {
120-
m := u.EffectiveMessage()
119+
func hasPhoto(c *botapi.Context) bool {
120+
m := c.Message()
121121
return m != nil && len(m.Photo) > 0
122122
}
123123

124124
// hasDocument matches messages that carry a document.
125-
func hasDocument(u *botapi.Update) bool {
126-
m := u.EffectiveMessage()
125+
func hasDocument(c *botapi.Context) bool {
126+
m := c.Message()
127127
return m != nil && m.Document != nil
128128
}

examples/middleware/main.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"os"
6+
"os/signal"
7+
"strconv"
8+
"time"
9+
10+
"github.com/gotd/log/logzap"
11+
"go.uber.org/zap"
12+
13+
"github.com/gotd/botapi"
14+
"github.com/gotd/botapi/storage"
15+
)
16+
17+
func main() {
18+
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
19+
defer cancel()
20+
21+
log, _ := zap.NewProduction()
22+
defer func() { _ = log.Sync() }()
23+
24+
store, err := storage.Open(os.Getenv("STORAGE_PATH"))
25+
if err != nil {
26+
log.Fatal("Open storage", zap.Error(err))
27+
}
28+
29+
defer func() { _ = store.Close() }()
30+
31+
appID, err := strconv.Atoi(os.Getenv("APP_ID"))
32+
if err != nil {
33+
log.Fatal("App ID", zap.Error(err))
34+
}
35+
36+
bot, err := botapi.New(os.Getenv("BOT_TOKEN"), botapi.Options{
37+
AppID: appID,
38+
AppHash: os.Getenv("APP_HASH"),
39+
Logger: logzap.New(log),
40+
Storage: store,
41+
FloodWait: true,
42+
})
43+
if err != nil {
44+
log.Fatal("Create bot", zap.Error(err))
45+
}
46+
47+
bot.UseOuter(func(next botapi.Handler) botapi.Handler {
48+
return func(c *botapi.Context) error {
49+
return next(c)
50+
}
51+
})
52+
53+
bot.Use(botapi.Recover(), botapi.Timeout(time.Minute), botapi.Logging())
54+
55+
log.Info("Starting bot")
56+
57+
if err := bot.Run(ctx); err != nil {
58+
log.Error("Run", zap.Error(err))
59+
}
60+
}

handler.go

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ type Handler func(c *Context) error
2121

2222
// Predicate reports whether a Handler should run for an update. A Handler runs
2323
// only if all of its predicates return true.
24-
type Predicate func(u *Update) bool
24+
type Predicate func(c *Context) bool
2525

2626
// Middleware wraps a Handler, returning a new one. Middleware registered with
2727
// Bot.Use runs for every handled update, outermost first.
@@ -33,9 +33,9 @@ type route struct {
3333
mws []Middleware
3434
}
3535

36-
func (r route) matches(u *Update) bool {
36+
func (r route) matches(c *Context) bool {
3737
for _, p := range r.predicates {
38-
if !p(u) {
38+
if !p(c) {
3939
return false
4040
}
4141
}
@@ -49,6 +49,7 @@ type router struct {
4949
mu sync.RWMutex
5050
routes []route
5151
mws []Middleware
52+
preMws []Middleware
5253
}
5354

5455
// Use registers global middleware applied to every handled update. Middleware
@@ -60,6 +61,16 @@ func (b *Bot) Use(mws ...Middleware) {
6061
b.router.mws = append(b.router.mws, mws...)
6162
}
6263

64+
// UseOuter registers global middleware applied to every update BEFORE route matching.
65+
// This is the outermost layer of the pipeline, useful for logging, recovery, and tracing.
66+
// Middleware runs outermost-first in registration order. Call before Run.
67+
func (b *Bot) UseOuter(mws ...Middleware) {
68+
b.router.mu.Lock()
69+
defer b.router.mu.Unlock()
70+
71+
b.router.preMws = append(b.router.preMws, mws...)
72+
}
73+
6374
// on registers a handler guarded by the given predicates.
6475
func (b *Bot) on(handler Handler, predicates ...Predicate) {
6576
b.onWith(handler, nil, predicates)
@@ -82,31 +93,41 @@ func (b *Bot) route(ctx context.Context, u *Update) {
8293
u.botUsername = b.self.Username
8394
}
8495

96+
c := &Context{Context: ctx, Bot: b, Update: u}
97+
8598
b.router.mu.RLock()
8699

100+
preMws := b.router.preMws
87101
routes := b.router.routes
88102
mws := b.router.mws
89103
b.router.mu.RUnlock()
90104

91-
for _, r := range routes {
92-
if !r.matches(u) {
93-
continue
94-
}
105+
var routingHandler Handler = func(c *Context) error {
106+
for _, r := range routes {
107+
if !r.matches(c) {
108+
continue
109+
}
95110

96-
h := r.handler
97-
for i := len(r.mws) - 1; i >= 0; i-- {
98-
h = r.mws[i](h)
99-
}
111+
h := r.handler
112+
for i := len(r.mws) - 1; i >= 0; i-- {
113+
h = r.mws[i](h)
114+
}
100115

101-
for i := len(mws) - 1; i >= 0; i-- {
102-
h = mws[i](h)
103-
}
116+
for i := len(mws) - 1; i >= 0; i-- {
117+
h = mws[i](h)
118+
}
104119

105-
c := &Context{Context: ctx, Bot: b, Update: u}
106-
if err := h(c); err != nil {
107-
b.logger().Error(ctx, "Handler error", log.Error(err))
120+
return h(c)
108121
}
109122

110-
return
123+
return nil
124+
}
125+
126+
for i := len(preMws) - 1; i >= 0; i-- {
127+
routingHandler = preMws[i](routingHandler)
128+
}
129+
130+
if err := routingHandler(c); err != nil {
131+
b.logger().Error(c.Context, "Pipeline error", log.Error(err))
111132
}
112133
}

handler_test.go

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ func TestRouterFirstMatchWins(t *testing.T) {
1111

1212
var calls []string
1313

14-
b.on(func(c *Context) error { calls = append(calls, "skipped"); return nil }, func(u *Update) bool { return false })
14+
b.on(func(c *Context) error { calls = append(calls, "skipped"); return nil }, func(c *Context) bool { return false })
1515
b.on(func(c *Context) error { calls = append(calls, "matched"); return nil })
1616
b.on(func(c *Context) error { calls = append(calls, "second-match"); return nil })
1717

@@ -59,6 +59,45 @@ func TestMiddlewareOrder(t *testing.T) {
5959
}
6060
}
6161

62+
func TestOuterMiddlewareOrder(t *testing.T) {
63+
b := newTestBot(t)
64+
65+
var order []string
66+
67+
b.UseOuter(func(next Handler) Handler {
68+
return func(c *Context) error {
69+
order = append(order, "outer")
70+
return next(c)
71+
}
72+
})
73+
74+
b.Use(func(next Handler) Handler {
75+
return func(c *Context) error {
76+
order = append(order, "global")
77+
return next(c)
78+
}
79+
})
80+
81+
b.on(func(c *Context) error {
82+
order = append(order, "handler")
83+
return nil
84+
})
85+
86+
b.route(context.Background(), &Update{})
87+
88+
want := []string{"outer", "global", "handler"}
89+
90+
if len(order) != len(want) {
91+
t.Fatalf("order = %v, want %v", order, want)
92+
}
93+
94+
for i := range want {
95+
if order[i] != want[i] {
96+
t.Fatalf("order = %v, want %v", order, want)
97+
}
98+
}
99+
}
100+
62101
func TestRouterHandlerErrorIsContained(t *testing.T) {
63102
b := newTestBot(t)
64103
b.on(func(c *Context) error { return errors.New("boom") })

0 commit comments

Comments
 (0)