Telego Handler Groups: Organizing Routes, Predicates, and Middleware in Telegram Bots

Telego handler groups provide a hierarchical tree structure in the telegohandler package that bundles related routes, predicates, and middleware, enabling modular bot architecture with deterministic routing order.

The mymmrac/telego library provides a robust telegohandler package for building HTTP‑style processing pipelines for Telegram updates. While flat handler registries suffice for simple bots, production applications require Telego handler groups to organize routes into logical, reusable hierarchies that scope middleware and predicates to specific features.

Core Architecture of Handler Groups

The implementation in telegohandler/handler_group.go defines the tree structure that powers routing.

HandlerGroup Structure

The HandlerGroup struct acts as a node in the routing tree. It maintains a slice of routes ([]route) and tracks its parent group, enabling traversal back up the tree after a subgroup finishes processing. This parent reference is critical for the hierarchical execution model implemented in HandleUpdate.

Route Internals

Each route struct holds the matching logic and execution target:

  • predicates: A slice of Predicate functions (func(context.Context, telego.Update) bool) that filter updates
  • handler: The concrete Handler to execute
  • group: Optional pointer to a nested *HandlerGroup
  • Parent group reference for context management

A route without predicates always matches, while middleware is implemented as a handler that calls ctx.Next(update) to continue the chain.

Execution Context

The Context.Next method in telegohandler/context.go drives the execution flow. It creates a fresh Context for each update, initializes the execution stack with [-1], and iterates over routes. When encountering a subgroup, it evaluates predicates, descends into the group, pushes a new stack entry, and continues scanning. After a handler returns, the stack unwinds to resume parent group processing.

Building Handler Groups in Practice

Constructing a handler group involves chaining methods on the BotHandler or parent groups. The Group method creates a new HandlerGroup with specified predicates, while Use registers middleware and Handle registers terminal handlers.

// Create the root bot handler attached to the update channel
bh, _ := th.NewBotHandler(bot, updates)

// Global middleware runs for every update before any group logic
bh.Use(th.PanicRecovery())
bh.Use(func(ctx *th.Context, upd telego.Update) error {
    fmt.Println("global middleware")
    return ctx.Next(upd) // continue processing
})

// Create a subgroup that only reacts to messages containing "task"
taskGroup := bh.Group(th.TextContains("task"))

// Group-specific middleware runs after global ones but before handlers
taskGroup.Use(func(ctx *th.Context, upd telego.Update) error {
    fmt.Println("group middleware")
    // Short-circuit if message is too long
    if len(upd.Message.Text) > 100 {
        return nil // stop processing
    }
    return ctx.Next(upd)
})

// Terminal handler for the task group
taskGroup.HandleMessage(func(ctx *th.Context, msg telego.Message) error {
    fmt.Println("handling task:", msg.Text)
    return nil
})

The bh.Group call creates a new HandlerGroup with parent pointing to bh's root group. Middleware appended via taskGroup.Use executes only when the group's predicates match, keeping logic modular and reusable.

Execution Flow Deep Dive

Understanding the traversal algorithm clarifies how Telego handler groups achieve deterministic routing.

Depth Calculation and Stack Initialization

Before processing begins, HandlerGroup.depth walks the tree to determine maximum nesting levels. This value pre-allocates the execution stack in HandleUpdate, which initializes stack []int with [-1] as the starting index.

Update Processing Steps

  1. Entry Point: BotHandler.Start() receives an update and invokes rootGroup.HandleUpdate in telegohandler/handler_group.go.

  2. Context Creation: HandleUpdate instantiates a Context with finalGroup pointing to the root and the pre-allocated stack.

  3. Route Iteration: Context.Next iterates over c.group.routes. For each route:

    • Evaluates all predicates against the update
    • If predicates pass and route.handler is set, invokes the handler
    • If predicates pass and route.group is set, descends into the subgroup
  4. Subgroup Descent: When entering a subgroup, Context sets c.group = subGroup, pushes the current route index to stack, and resets the iteration index to continue inside the nested group.

  5. Handler Return: After a handler executes, control returns to Context.Next, which checks for remaining routes in the current group. If none exist, it unwinds the stack to resume parent group processing.

  6. Short-Circuiting: Middleware can halt execution by returning without calling ctx.Next(update), preventing downstream routes from processing the update.

The algorithm guarantees that only the first matching handler in a group executes, while middleware chains can modify the update or terminate the flow at any level.

Practical Patterns and Examples

These patterns from examples/handler_groups_and_middleware/main.go demonstrate common use cases for Telego handler groups.

Global Middleware with Terminal Handler

Apply logging or recovery to all updates before routing to specific handlers.

bh.Use(func(ctx *th.Context, upd telego.Update) error {
    log.Println("received update:", upd.UpdateID)
    return ctx.Next(upd) // forward to next route
})

bh.HandleMessage(func(ctx *th.Context, msg telego.Message) error {
    _, _ = ctx.Bot().SendMessage(tu.Messagef(
        tu.ID(msg.Chat.ID), "Echo: %s", msg.Text))
    return nil
}, th.AnyMessage())

Nested Groups with Authentication

Scope sensitive commands to authorized users using group-level middleware.

admin := bh.Group(th.CommandEqual("admin"))          // matches only /admin commands
admin.Use(func(ctx *th.Context, upd telego.Update) error {
    if !isAdmin(upd.Message.From.ID) {
        return nil // silently ignore non-admins
    }
    return ctx.Next(upd)
})

admin.HandleMessage(func(ctx *th.Context, msg telego.Message) error {
    _, _ = ctx.Bot().SendMessage(tu.Message(tu.ID(msg.Chat.ID),
        "Welcome, admin!"))
    return nil
})

Early-Exit Middleware for Rate Limiting

Prevent downstream processing for rate-limited users without modifying handlers.

bh.Use(func(ctx *th.Context, upd telego.Update) error {
    if limited(upd.Message.From.ID) {
        // Do not call ctx.Next – stops processing for this update
        return nil
    }
    return ctx.Next(upd)
})

Summary

  • Telego handler groups in mymmrac/telego implement a hierarchical tree structure for organizing Telegram bot routes, defined in telegohandler/handler_group.go.
  • Each HandlerGroup maintains a slice of routes and a parent reference, enabling nested middleware scoping and deterministic routing priority.
  • The route struct combines predicates (filtering functions), handlers (terminal processing), and optional subgroups for recursive traversal.
  • Execution flows through HandleUpdate and Context.Next, using a pre-allocated stack based on depth() calculations to manage tree traversal.
  • Middleware short-circuits the chain by returning without calling ctx.Next, while only the first matching handler per group executes.

Frequently Asked Questions

What is the difference between a handler and middleware in Telego?

In the telegohandler package, both handlers and middleware conform to the Handler function signature (func(context.Context, telego.Update) error). The critical distinction is execution flow: middleware must call ctx.Next(update) to pass control to the next route in the chain, while a terminal handler returns directly without invoking ctx.Next, finalizing processing for that branch. Middleware is registered via Use(), while terminal handlers use methods like HandleMessage() or Handle().

How do predicates work in handler groups?

Predicates are functions of type func(context.Context, telego.Update) bool defined in telegohandler/handler_group.go. Each route stores a slice of predicates that must all evaluate to true for the route to match. When Context.Next processes an update, it evaluates predicates before invoking handlers or descending into subgroups. A route with no predicates always matches. Common predicates include th.TextContains(), th.CommandEqual(), and th.AnyMessage(), which can be combined using th.And() or th.Or().

Can handler groups be nested multiple levels deep?

Yes, the HandlerGroup struct supports arbitrary nesting through its parent field and the Group() method. When you call bh.Group(predicate), the method creates a new HandlerGroup with its parent set to the current group and appends it as a route. The depth() method calculates the maximum nesting level by walking parent references, and HandleUpdate pre-allocates an execution stack based on this depth. You can create subgroups for admin commands, feature modules, or rate-limited endpoints, each with isolated middleware chains.

How do I stop processing an update in middleware?

To short-circuit the processing chain, return nil (or an error) from your middleware without calling ctx.Next(update). The Context.Next method only continues to subsequent routes if the current handler explicitly invokes ctx.Next. This pattern is commonly used for authentication checks, rate limiting, or spam filtering where you want to prevent the update from reaching downstream handlers. For example, if a user exceeds rate limits, simply return nil to drop the update silently.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →