From 949f525e5f819adf5a63fcc94c1bf2b2e666f2cf Mon Sep 17 00:00:00 2001 From: fluffur Date: Wed, 17 Jun 2026 14:45:48 +0500 Subject: [PATCH 1/6] feat(router)!: allow predicates to access context Predicates can now read database configurations, flags, and other data pre-loaded by the new UseOuter middleware layer. BREAKING CHANGE: The `Predicate` type signature changed from `func(u *Update) bool` to `func(c *Context) bool`. To fix this, update your custom predicate functions to accept `*Context` and read the update via `c.Update` if needed. --- business_updates.go | 8 +++--- docs/guide.md | 8 +++--- examples/advanced/main.go | 20 +++++++------- examples/demo/helpers.go | 20 +++++++------- examples/media/main.go | 8 +++--- handler.go | 55 +++++++++++++++++++++++++-------------- handler_test.go | 2 +- on.go | 16 ++++++------ predicates.go | 40 ++++++++++++++-------------- 9 files changed, 97 insertions(+), 80 deletions(-) diff --git a/business_updates.go b/business_updates.go index 34964df..b9fd493 100644 --- a/business_updates.go +++ b/business_updates.go @@ -133,10 +133,10 @@ func (u *Update) businessConnectionID() string { } // Kind predicates for business updates. -func hasBusinessMessage(u *Update) bool { return u.BusinessMessage != nil } -func hasEditedBusinessMessage(u *Update) bool { return u.EditedBusinessMessage != nil } -func hasBusinessConnection(u *Update) bool { return u.BusinessConnection != nil } -func hasDeletedBusinessMessages(u *Update) bool { return u.DeletedBusinessMessages != nil } +func hasBusinessMessage(c *Context) bool { return c.Update.BusinessMessage != nil } +func hasEditedBusinessMessage(c *Context) bool { return c.Update.EditedBusinessMessage != nil } +func hasBusinessConnection(c *Context) bool { return c.Update.BusinessConnection != nil } +func hasDeletedBusinessMessages(c *Context) bool { return c.Update.DeletedBusinessMessages != nil } // OnBusinessMessage registers a handler for new messages from a connected // business account. diff --git a/docs/guide.md b/docs/guide.md index 44d3ca3..c161bad 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -251,8 +251,8 @@ queue. (`PeerRef` is for sending; chat-management methods still take a resolved ## Predicates -Every `On*` method accepts trailing `Predicate`s (`func(*Update) bool`); the -handler runs only when all match. First match wins across handlers. +Every `On*` method accepts trailing `Predicate`s (`func(*botapi.Context) bool`); +the handler runs only when all match. First match wins across handlers. ```go bot.OnMessage(handler, botapi.HasText(), botapi.Not(botapi.HasPrefix("/"))) @@ -263,8 +263,8 @@ Built-ins: `Command`, `HasPrefix`, `HasText`, `TextEquals`, `Regex`, Write your own — it's just a function: ```go -func hasPhoto(u *botapi.Update) bool { - m := u.EffectiveMessage() +func hasPhoto(c *botapi.Context) bool { + m := c.Message() return m != nil && len(m.Photo) > 0 } ``` diff --git a/examples/advanced/main.go b/examples/advanced/main.go index 785ac11..cc60b67 100644 --- a/examples/advanced/main.go +++ b/examples/advanced/main.go @@ -435,28 +435,28 @@ func reverse(s string) string { return string(r) } -func hasPhoto(u *botapi.Update) bool { - m := u.EffectiveMessage() +func hasPhoto(c *botapi.Context) bool { + m := c.Message() return m != nil && len(m.Photo) > 0 } -func hasDocument(u *botapi.Update) bool { - m := u.EffectiveMessage() +func hasDocument(c *botapi.Context) bool { + m := c.Message() return m != nil && m.Document != nil } -func hasSticker(u *botapi.Update) bool { - m := u.EffectiveMessage() +func hasSticker(c *botapi.Context) bool { + m := c.Message() return m != nil && m.Sticker != nil } -func hasLocation(u *botapi.Update) bool { - m := u.EffectiveMessage() +func hasLocation(c *botapi.Context) bool { + m := c.Message() return m != nil && m.Location != nil } -func hasContact(u *botapi.Update) bool { - m := u.EffectiveMessage() +func hasContact(c *botapi.Context) bool { + m := c.Message() return m != nil && m.Contact != nil } diff --git a/examples/demo/helpers.go b/examples/demo/helpers.go index 88da7dc..c798264 100644 --- a/examples/demo/helpers.go +++ b/examples/demo/helpers.go @@ -26,28 +26,28 @@ func reverse(s string) string { // --- incoming-media predicates, shared by media.go --- -func hasPhoto(u *botapi.Update) bool { - m := u.EffectiveMessage() +func hasPhoto(c *botapi.Context) bool { + m := c.Message() return m != nil && len(m.Photo) > 0 } -func hasDocument(u *botapi.Update) bool { - m := u.EffectiveMessage() +func hasDocument(c *botapi.Context) bool { + m := c.Message() return m != nil && m.Document != nil } -func hasSticker(u *botapi.Update) bool { - m := u.EffectiveMessage() +func hasSticker(c *botapi.Context) bool { + m := c.Message() return m != nil && m.Sticker != nil } -func hasLocation(u *botapi.Update) bool { - m := u.EffectiveMessage() +func hasLocation(c *botapi.Context) bool { + m := c.Message() return m != nil && m.Location != nil } -func hasContact(u *botapi.Update) bool { - m := u.EffectiveMessage() +func hasContact(c *botapi.Context) bool { + m := c.Message() return m != nil && m.Contact != nil } diff --git a/examples/media/main.go b/examples/media/main.go index 55a111f..4edfca2 100644 --- a/examples/media/main.go +++ b/examples/media/main.go @@ -116,13 +116,13 @@ func main() { } // hasPhoto matches messages that carry a photo. -func hasPhoto(u *botapi.Update) bool { - m := u.EffectiveMessage() +func hasPhoto(c *botapi.Context) bool { + m := c.Message() return m != nil && len(m.Photo) > 0 } // hasDocument matches messages that carry a document. -func hasDocument(u *botapi.Update) bool { - m := u.EffectiveMessage() +func hasDocument(c *botapi.Context) bool { + m := c.Message() return m != nil && m.Document != nil } diff --git a/handler.go b/handler.go index d3500a6..9859065 100644 --- a/handler.go +++ b/handler.go @@ -21,7 +21,7 @@ type Handler func(c *Context) error // Predicate reports whether a Handler should run for an update. A Handler runs // only if all of its predicates return true. -type Predicate func(u *Update) bool +type Predicate func(c *Context) bool // Middleware wraps a Handler, returning a new one. Middleware registered with // Bot.Use runs for every handled update, outermost first. @@ -33,9 +33,9 @@ type route struct { mws []Middleware } -func (r route) matches(u *Update) bool { +func (r route) matches(c *Context) bool { for _, p := range r.predicates { - if !p(u) { + if !p(c) { return false } } @@ -49,6 +49,7 @@ type router struct { mu sync.RWMutex routes []route mws []Middleware + preMws []Middleware } // Use registers global middleware applied to every handled update. Middleware @@ -60,6 +61,16 @@ func (b *Bot) Use(mws ...Middleware) { b.router.mws = append(b.router.mws, mws...) } +// UseOuter registers global middleware applied to every update BEFORE route matching. +// This is the outermost layer of the pipeline, useful for logging, recovery, and tracing. +// Middleware runs outermost-first in registration order. Call before Run. +func (b *Bot) UseOuter(mws ...Middleware) { + b.router.mu.Lock() + defer b.router.mu.Unlock() + + b.router.preMws = append(b.router.preMws, mws...) +} + // on registers a handler guarded by the given predicates. func (b *Bot) on(handler Handler, predicates ...Predicate) { b.onWith(handler, nil, predicates) @@ -81,32 +92,38 @@ func (b *Bot) route(ctx context.Context, u *Update) { if b.self != nil { u.botUsername = b.self.Username } + c := &Context{Context: ctx, Bot: b, Update: u} b.router.mu.RLock() - + preMws := b.router.preMws routes := b.router.routes mws := b.router.mws b.router.mu.RUnlock() - for _, r := range routes { - if !r.matches(u) { - continue - } + var routingHandler Handler = func(c *Context) error { + for _, r := range routes { + if !r.matches(c) { + continue + } - h := r.handler - for i := len(r.mws) - 1; i >= 0; i-- { - h = r.mws[i](h) - } + h := r.handler + for i := len(r.mws) - 1; i >= 0; i-- { + h = r.mws[i](h) + } - for i := len(mws) - 1; i >= 0; i-- { - h = mws[i](h) - } + for i := len(mws) - 1; i >= 0; i-- { + h = mws[i](h) + } - c := &Context{Context: ctx, Bot: b, Update: u} - if err := h(c); err != nil { - b.logger().Error(ctx, "Handler error", log.Error(err)) + return h(c) } + return nil + } + for i := len(preMws) - 1; i >= 0; i-- { + routingHandler = preMws[i](routingHandler) + } - return + if err := routingHandler(c); err != nil { + b.logger().Error(c.Context, "Pipeline error", log.Error(err)) } } diff --git a/handler_test.go b/handler_test.go index b740654..c481b9a 100644 --- a/handler_test.go +++ b/handler_test.go @@ -11,7 +11,7 @@ func TestRouterFirstMatchWins(t *testing.T) { var calls []string - b.on(func(c *Context) error { calls = append(calls, "skipped"); return nil }, func(u *Update) bool { return false }) + b.on(func(c *Context) error { calls = append(calls, "skipped"); return nil }, func(c *Context) bool { return false }) b.on(func(c *Context) error { calls = append(calls, "matched"); return nil }) b.on(func(c *Context) error { calls = append(calls, "second-match"); return nil }) diff --git a/on.go b/on.go index 2ac3bd4..f67afd4 100644 --- a/on.go +++ b/on.go @@ -104,14 +104,14 @@ func (b *Bot) dispatchMessage(ctx context.Context, msg tg.MessageClass, edited b // Kind predicates select an update by which field it carries. They are shared // by Bot.On* and Group.On*. -func hasMessage(u *Update) bool { return u.Message != nil } -func hasEditedMessage(u *Update) bool { return u.EditedMessage != nil } -func hasChannelPost(u *Update) bool { return u.ChannelPost != nil } -func hasCallbackQuery(u *Update) bool { return u.CallbackQuery != nil } -func hasInlineQuery(u *Update) bool { return u.InlineQuery != nil } -func hasChosenInlineResult(u *Update) bool { return u.ChosenInlineResult != nil } -func hasShippingQuery(u *Update) bool { return u.ShippingQuery != nil } -func hasPreCheckoutQuery(u *Update) bool { return u.PreCheckoutQuery != nil } +func hasMessage(c *Context) bool { return c.Update.Message != nil } +func hasEditedMessage(c *Context) bool { return c.Update.EditedMessage != nil } +func hasChannelPost(c *Context) bool { return c.Update.ChannelPost != nil } +func hasCallbackQuery(c *Context) bool { return c.Update.CallbackQuery != nil } +func hasInlineQuery(c *Context) bool { return c.Update.InlineQuery != nil } +func hasChosenInlineResult(c *Context) bool { return c.Update.ChosenInlineResult != nil } +func hasShippingQuery(c *Context) bool { return c.Update.ShippingQuery != nil } +func hasPreCheckoutQuery(c *Context) bool { return c.Update.PreCheckoutQuery != nil } // OnMessage registers a handler for new messages matching the predicates. func (b *Bot) OnMessage(h Handler, predicates ...Predicate) { diff --git a/predicates.go b/predicates.go index 504dca5..7b0192b 100644 --- a/predicates.go +++ b/predicates.go @@ -69,8 +69,8 @@ func commandName(text string) (name, target string, ok bool) { func Command(name string) Predicate { name = strings.TrimPrefix(name, "/") - return func(u *Update) bool { - m := u.EffectiveMessage() + return func(c *Context) bool { + m := c.Message() if m == nil { return false } @@ -80,30 +80,30 @@ func Command(name string) Predicate { return false } - return target == "" || strings.EqualFold(target, u.botUsername) + return target == "" || strings.EqualFold(target, c.Update.botUsername) } } // HasPrefix matches a message whose text starts with prefix. func HasPrefix(prefix string) Predicate { - return func(u *Update) bool { - m := u.EffectiveMessage() + return func(c *Context) bool { + m := c.Message() return m != nil && strings.HasPrefix(m.Text, prefix) } } // HasText matches any message that carries non-empty text. func HasText() Predicate { - return func(u *Update) bool { - m := u.EffectiveMessage() + return func(c *Context) bool { + m := c.Message() return m != nil && m.Text != "" } } // TextEquals matches a message whose text equals s exactly. func TextEquals(s string) Predicate { - return func(u *Update) bool { - m := u.EffectiveMessage() + return func(c *Context) bool { + m := c.Message() return m != nil && m.Text == s } } @@ -113,44 +113,44 @@ func TextEquals(s string) Predicate { func Regex(pattern string) Predicate { re := regexp.MustCompile(pattern) - return func(u *Update) bool { - m := u.EffectiveMessage() + return func(c *Context) bool { + m := c.Message() return m != nil && re.MatchString(m.Text) } } // ChatTypeIs matches a message sent in a chat of the given type. func ChatTypeIs(t ChatType) Predicate { - return func(u *Update) bool { - m := u.EffectiveMessage() + return func(c *Context) bool { + m := c.Message() return m != nil && m.Chat.Type == t } } // CallbackData matches a callback query whose data equals s. func CallbackData(s string) Predicate { - return func(u *Update) bool { - return u.CallbackQuery != nil && u.CallbackQuery.Data == s + return func(c *Context) bool { + return c.Update.CallbackQuery != nil && c.Update.CallbackQuery.Data == s } } // CallbackPrefix matches a callback query whose data starts with prefix. func CallbackPrefix(prefix string) Predicate { - return func(u *Update) bool { - return u.CallbackQuery != nil && strings.HasPrefix(u.CallbackQuery.Data, prefix) + return func(c *Context) bool { + return c.Update.CallbackQuery != nil && strings.HasPrefix(c.Update.CallbackQuery.Data, prefix) } } // Not inverts a predicate. func Not(p Predicate) Predicate { - return func(u *Update) bool { return !p(u) } + return func(c *Context) bool { return !p(c) } } // Or matches when any of the given predicates matches. func Or(predicates ...Predicate) Predicate { - return func(u *Update) bool { + return func(c *Context) bool { for _, p := range predicates { - if p(u) { + if p(c) { return true } } From 69faa52a061c671c6b10dd4c5cb776a0572a9663 Mon Sep 17 00:00:00 2001 From: fluffur Date: Wed, 17 Jun 2026 20:47:54 +0500 Subject: [PATCH 2/6] feat: add pre routing middlewares example --- examples/middleware/main.go | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 examples/middleware/main.go diff --git a/examples/middleware/main.go b/examples/middleware/main.go new file mode 100644 index 0000000..3c4412a --- /dev/null +++ b/examples/middleware/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "os" + "os/signal" + "strconv" + "time" + + "github.com/gotd/botapi" + "github.com/gotd/botapi/storage" + "github.com/gotd/log/logzap" + "go.uber.org/zap" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + log, _ := zap.NewProduction() + defer func() { _ = log.Sync() }() + + store, err := storage.Open(os.Getenv("STORAGE_PATH")) + if err != nil { + log.Fatal("Open storage", zap.Error(err)) + } + defer func() { _ = store.Close() }() + + appID, err := strconv.Atoi(os.Getenv("APP_ID")) + if err != nil { + log.Fatal("App ID", zap.Error(err)) + } + + bot, err := botapi.New(os.Getenv("BOT_TOKEN"), botapi.Options{ + AppID: appID, + AppHash: os.Getenv("APP_HASH"), + Logger: logzap.New(log), + Storage: store, + FloodWait: true, + }) + if err != nil { + log.Fatal("Create bot", zap.Error(err)) + } + + bot.UseOuter(func(next botapi.Handler) botapi.Handler { + return func(c *botapi.Context) error { + return next(c) + } + }) + + bot.Use(botapi.Recover(), botapi.Timeout(time.Minute), botapi.Logging()) + + log.Info("Starting bot") + if err := bot.Run(ctx); err != nil { + log.Fatal("Run", zap.Error(err)) + } +} From c7f442572d01e96287d21e3fbfacc44b0c0d26ba Mon Sep 17 00:00:00 2001 From: fluffur Date: Wed, 17 Jun 2026 21:01:37 +0500 Subject: [PATCH 3/6] docs(readme): document UseOuter middleware and updated Predicate signature Added a dedicated section for "Outer Middleware" in the README to explain the middleware lifecycle and pipeline execution order. BREAKING CHANGE: Updated examples and documentation to reflect that `Predicate` now accepts `*Context` instead of `*Update`. --- docs/guide.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/guide.md b/docs/guide.md index c161bad..294a693 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -280,6 +280,27 @@ bot.Use(botapi.Recover(), botapi.Timeout(30*time.Second), botapi.Logging()) Built-ins: `Recover` (turns panics into errors), `Timeout`, `Logging`. +## Outer Middleware + +An `OuterMiddleware` is also a `func(Handler) Handler`. Register global middleware +that runs before route matching with `UseOuter`: +```go +bot.UseOuter(botapi.Recover(), ChatConfigMiddleware()) +``` + +See [`examples/middleware`](../examples/middleware) for a complete example +showing how data flows from outer middleware to predicates and handlers. + +### Pipeline Execution Order + +Middlewares and handlers execute in a distinct lifecycle layer (from the outside in). +The visualization below demonstrates how an update travels through the bot: + +1. **`UseOuter`** (Always runs first; can short-circuit or inject data into `Context`). +2. **`Predicate` matching** (Evaluates route guards; has access to data from `UseOuter`). +3. **`Use`** (Runs only if a route matches; ideal for logging, telemetry, or lazy-loading user sessions). +4. **`Handler`** (Your core business logic). + ## Groups `Group` scopes shared predicates and middleware to a subset of handlers: From 87efd501a3825280e79b3517a87adc8ff2df3473 Mon Sep 17 00:00:00 2001 From: fluffur Date: Wed, 17 Jun 2026 21:21:18 +0500 Subject: [PATCH 4/6] test(router): fix predicates and update tests to use Context --- predicates_test.go | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/predicates_test.go b/predicates_test.go index b7a5976..81be49e 100644 --- a/predicates_test.go +++ b/predicates_test.go @@ -26,7 +26,7 @@ func TestCommandName(t *testing.T) { func TestCommandPredicate(t *testing.T) { // Untargeted command always matches. - plain := &Update{Message: &Message{Text: "/start hi"}} + plain := &Context{Update: &Update{Message: &Message{Text: "/start hi"}}} if !Command("start")(plain) || !Command("/start")(plain) { t.Fatal("Command should match with and without slash") } @@ -35,54 +35,56 @@ func TestCommandPredicate(t *testing.T) { t.Fatal("Command should not match a different command") } - if Command("start")(&Update{CallbackQuery: &CallbackQuery{}}) { + nonMsg := &Context{Update: &Update{CallbackQuery: &CallbackQuery{}}} + if Command("start")(nonMsg) { t.Fatal("Command should not match a non-message update") } // Targeted command matches only when the @target is this bot (case-insensitive). - mine := &Update{Message: &Message{Text: "/start@MyBot hi"}, botUsername: "mybot"} + // Если поле botUsername находится в Update: + mine := &Context{Update: &Update{Message: &Message{Text: "/start@MyBot hi"}, botUsername: "mybot"}} if !Command("start")(mine) { t.Fatal("Command should match when targeted at this bot") } - other := &Update{Message: &Message{Text: "/start@other_bot hi"}, botUsername: "mybot"} + other := &Context{Update: &Update{Message: &Message{Text: "/start@other_bot hi"}, botUsername: "mybot"}} if Command("start")(other) { t.Fatal("Command should not match when targeted at another bot") } // Targeted command with an unknown bot username does not match. - unknown := &Update{Message: &Message{Text: "/start@mybot hi"}} + unknown := &Context{Update: &Update{Message: &Message{Text: "/start@mybot hi"}}} if Command("start")(unknown) { t.Fatal("Command should not match a targeted command when the bot username is unknown") } } func TestTextAndChatPredicates(t *testing.T) { - u := &Update{Message: &Message{Text: "hello world", Chat: Chat{Type: ChatTypePrivate}}} - if !HasPrefix("hello")(u) || !HasText()(u) || !Regex(`^hello`)(u) { + c := &Context{Update: &Update{Message: &Message{Text: "hello world", Chat: Chat{Type: ChatTypePrivate}}}} + if !HasPrefix("hello")(c) || !HasText()(c) || !Regex(`^hello`)(c) { t.Fatal("text predicates should match") } - if !ChatTypeIs(ChatTypePrivate)(u) || ChatTypeIs(ChatTypeChannel)(u) { + if !ChatTypeIs(ChatTypePrivate)(c) || ChatTypeIs(ChatTypeChannel)(c) { t.Fatal("ChatTypeIs mismatch") } - if !Not(TextEquals("nope"))(u) { + if !Not(TextEquals("nope"))(c) { t.Fatal("Not should invert") } } func TestCallbackPredicates(t *testing.T) { - u := &Update{CallbackQuery: &CallbackQuery{Data: "vote:42"}} - if !CallbackPrefix("vote:")(u) || !CallbackData("vote:42")(u) { + c := &Context{Update: &Update{CallbackQuery: &CallbackQuery{Data: "vote:42"}}} + if !CallbackPrefix("vote:")(c) || !CallbackData("vote:42")(c) { t.Fatal("callback predicates should match") } - if !Or(CallbackData("x"), CallbackPrefix("vote:"))(u) { + if !Or(CallbackData("x"), CallbackPrefix("vote:"))(c) { t.Fatal("Or should match when one matches") } - if u.Text() != "vote:42" { - t.Fatalf("Update.Text for callback = %q", u.Text()) + if c.Update.Text() != "vote:42" { + t.Fatalf("Update.Text for callback = %q", c.Update.Text()) } } From 76e2023f0a3d52c66371b3502c7bd8373240acac Mon Sep 17 00:00:00 2001 From: fluffur Date: Wed, 17 Jun 2026 21:41:21 +0500 Subject: [PATCH 5/6] style: fix code formatting and missing whitespaces --- examples/middleware/main.go | 9 ++++++--- handler.go | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/examples/middleware/main.go b/examples/middleware/main.go index 3c4412a..7f2f397 100644 --- a/examples/middleware/main.go +++ b/examples/middleware/main.go @@ -7,10 +7,11 @@ import ( "strconv" "time" - "github.com/gotd/botapi" - "github.com/gotd/botapi/storage" "github.com/gotd/log/logzap" "go.uber.org/zap" + + "github.com/gotd/botapi" + "github.com/gotd/botapi/storage" ) func main() { @@ -24,6 +25,7 @@ func main() { if err != nil { log.Fatal("Open storage", zap.Error(err)) } + defer func() { _ = store.Close() }() appID, err := strconv.Atoi(os.Getenv("APP_ID")) @@ -51,7 +53,8 @@ func main() { bot.Use(botapi.Recover(), botapi.Timeout(time.Minute), botapi.Logging()) log.Info("Starting bot") + if err := bot.Run(ctx); err != nil { - log.Fatal("Run", zap.Error(err)) + log.Error("Run", zap.Error(err)) } } diff --git a/handler.go b/handler.go index 9859065..fcb196a 100644 --- a/handler.go +++ b/handler.go @@ -92,9 +92,11 @@ func (b *Bot) route(ctx context.Context, u *Update) { if b.self != nil { u.botUsername = b.self.Username } + c := &Context{Context: ctx, Bot: b, Update: u} b.router.mu.RLock() + preMws := b.router.preMws routes := b.router.routes mws := b.router.mws @@ -117,8 +119,10 @@ func (b *Bot) route(ctx context.Context, u *Update) { return h(c) } + return nil } + for i := len(preMws) - 1; i >= 0; i-- { routingHandler = preMws[i](routingHandler) } From 2e6a290f617fdb3b4bd8faa16bfcb99bbd03431d Mon Sep 17 00:00:00 2001 From: fluffur Date: Thu, 18 Jun 2026 14:04:53 +0500 Subject: [PATCH 6/6] test: add coverage for outer middleware execution order --- handler_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/handler_test.go b/handler_test.go index c481b9a..aa3b124 100644 --- a/handler_test.go +++ b/handler_test.go @@ -59,6 +59,45 @@ func TestMiddlewareOrder(t *testing.T) { } } +func TestOuterMiddlewareOrder(t *testing.T) { + b := newTestBot(t) + + var order []string + + b.UseOuter(func(next Handler) Handler { + return func(c *Context) error { + order = append(order, "outer") + return next(c) + } + }) + + b.Use(func(next Handler) Handler { + return func(c *Context) error { + order = append(order, "global") + return next(c) + } + }) + + b.on(func(c *Context) error { + order = append(order, "handler") + return nil + }) + + b.route(context.Background(), &Update{}) + + want := []string{"outer", "global", "handler"} + + if len(order) != len(want) { + t.Fatalf("order = %v, want %v", order, want) + } + + for i := range want { + if order[i] != want[i] { + t.Fatalf("order = %v, want %v", order, want) + } + } +} + func TestRouterHandlerErrorIsContained(t *testing.T) { b := newTestBot(t) b.on(func(c *Context) error { return errors.New("boom") })