Telego Predicate-Based Routing: A Complete Guide to Telegram Bot Update Handling

Telego routes incoming Telegram updates to handlers using boolean predicate functions that inspect update contents, enabling composable filtering through logical operators like And, Or, and Not instead of manual conditional statements.

Telego is a comprehensive Go library for building Telegram bots. At the heart of its telegohandler package lies a predicate-based routing system that decouples update filtering from business logic. This architecture allows developers to construct complex routing trees using simple, reusable boolean functions rather than writing nested if statements.

Core Concepts of Telego Predicate-Based Routing

The routing mechanism rests on three fundamental structures defined in telegohandler/handler_group.go and telegohandler/predicates.go.

The Predicate Type

A Predicate is a first-class function that evaluates whether an update matches specific criteria:

// telegohandler/predicates.go
type Predicate func(context.Context, telego.Update) bool

Predicates receive the update context and the update itself, returning true only if the update should be routed to the associated handler.

Route Structure

The unexported route struct encapsulates a single routing entry:

// telegohandler/handler_group.go#L18-L31
type route struct {
    predicates []Predicate
    handler    Handler
    group      *HandlerGroup
}

Each route maintains a slice of predicates that must all evaluate to true for the route to match. Routes can terminate in a handler function, a middleware chain, or a nested sub-group.

HandlerGroup Tree

HandlerGroup forms a hierarchical tree structure:

// telegohandler/handler_group.go
type HandlerGroup struct {
    parent *HandlerGroup
    routes []route
    // ... other fields
}

The root group receives all updates and traverses its routes recursively. Child groups created via Group() inherit the parent's context but maintain their own predicate logic.

How Route Matching Works

When BotHandler receives an update, it delegates to HandlerGroup.HandleUpdate, which creates a Context and invokes Context.Next.

The Routing Algorithm

The Next method implements a depth-first traversal with backtracking:

// telegohandler/context.go#L10-L41
func (c *Context) Next(update telego.Update) error {
    // iterate over the current group's routes
    for i := c.stack[len(c.stack)-1] + 1; i < len(c.group.routes); i++ {
        r := c.group.routes[i]
        if r.match(c.ctx, update) {          // ← predicate check
            c.stack[len(c.stack)-1] = i

            if r.handler != nil {            // handler or middleware
                return r.handler(c, update)
            }
            // sub-group: descend recursively
            c.group = r.group
            c.stack = append(c.stack, -1)
            return c.Next(update)
        }
    }
    // back-track to parent groups when nothing matches
    if c.group.parent != nil && c.group != c.finalGroup {
        c.group = c.group.parent
        c.stack = c.stack[:len(c.stack)-1]
        return c.Next(update)
    }
    return nil
}

Predicate Evaluation

The route.match method evaluates predicates against a cloned update to prevent mutation:

// telegohandler/handler_group.go#L18-L31
func (r route) match(ctx context.Context, update telego.Update) bool {
    if len(r.predicates) == 0 {
        return true // no predicates → always match
    }
    // work on a copy so predicates cannot mutate the original update
    update = update.Clone()
    for _, p := range r.predicates {
        if !p(ctx, update) {
            return false
        }
    }
    return true
}

If all predicates return true, the route matches and the handler executes.

Built-in Predicates

The telegohandler/predicates.go file provides over 70 ready-to-use predicates covering every Telegram update field:

Predicate Description Signature Reference
Any() Always returns true predicates.go#L11-L15
None() Always returns false predicates.go#L18-L22
And(p1, p2, …) Logical AND of predicates predicates.go#L25-L34
Or(p1, p2, …) Logical OR of predicates predicates.go#L37-L46
Not(p) Logical NOT of a predicate predicates.go#L49-L54
AnyMessage() Update contains a non-nil Message predicates.go#L60-L64
AnyMessageWithText() Message present with non-empty Text predicates.go#L71-L75
CommandEqual("start") Message is command /start (case-insensitive) predicates.go#L25-L40
TextContains("hello") Message text contains substring predicates.go#L44-L48
CallbackDataPrefix("pay_") Callback query data starts with prefix predicates.go#L30-L34

All predicates compose naturally with And, Or, and Not, enabling complex filtering without nested conditionals.

Registering Routes and Middleware

The HandlerGroup API in telegohandler/handler_group.go provides three primary registration methods.

Handle

Registers a handler function with optional predicates:

// telegohandler/handler_group.go#L82-L100
func (h *HandlerGroup) Handle(handler Handler, predicates ...Predicate) {
    h.routes = append(h.routes, route{
        predicates: predicates,
        handler:    handler,
    })
}

Group

Creates a nested sub-group that inherits predicates:

// telegohandler/handler_group.go#L101-L121
func (h *HandlerGroup) Group(predicates ...Predicate) *HandlerGroup {
    group := &HandlerGroup{parent: h}
    h.routes = append(h.routes, route{
        predicates: predicates,
        group:      group,
    })
    return group
}

Use

Registers middleware that executes before handlers:

// telegohandler/handler_group.go#L123-L141
func (h *HandlerGroup) Use(middlewares ...Handler) {
    for _, m := range middlewares {
        h.routes = append(h.routes, route{handler: m})
    }
}

Practical Example: Command Echo Bot

This complete example demonstrates Telego predicate-based routing with middleware, grouped routes, and command filtering:

package main

import (
    "context"
    "log"

    "github.com/mymmrac/telego"
    "github.com/mymmrac/telego/telegohandler"
)

func main() {
    // Initialise bot (token omitted for security)
    bot, err := telego.NewBot("YOUR_BOT_TOKEN")
    if err != nil {
        log.Fatal(err)
    }

    // Create a handler group
    h := telegohandler.NewBotHandler(bot)

    // Middleware that logs every incoming update
    h.Use(func(ctx *telegohandler.Context, upd telego.Update) error {
        log.Printf("update %d from chat %d", upd.UpdateID, upd.Message.Chat.ID)
        return ctx.Next(upd) // continue routing
    })

    // Echo only text messages that start with "/say"
    h.Group(
        telegohandler.CommandPrefix("/say"), // matches /say, /sayHello, etc.
        telegohandler.AnyMessageWithText(), // ensure we have text
    ).HandleMessage(func(ctx *telegohandler.Context, msg telego.Message) error {
        // Echo the text after the command
        reply := telego.NewMessage(msg.Chat.ID, msg.Text[len("/say"):])
        _, err := bot.SendMessage(reply)
        return err
    })

    // Start long-polling
    err = bot.StartLongPolling(context.Background())
    if err != nil {
        log.Fatal(err)
    }
}

Key implementation details:

  • CommandPrefix("/say") and AnyMessageWithText() combine implicitly through the Group method, creating a predicate slice that requires both conditions.
  • The middleware calls ctx.Next(upd) to pass control to the next matching route, enabling the middleware chain pattern.
  • HandleMessage registers a final handler that executes only when all grouped predicates return true.

Creating Custom Predicates

When built-in predicates don't cover specific business logic, implement the Predicate type directly:

func FromUser(username string) telegohandler.Predicate {
    return func(_ context.Context, upd telego.Update) bool {
        return upd.Message != nil && upd.Message.From != nil &&
               strings.EqualFold(upd.Message.From.Username, username)
    }
}

Usage follows the same pattern as library predicates:

h.Group(telegohandler.AnyMessage(), FromUser("alice")).HandleMessage(myHandler)

This composability enables sophisticated routing trees—such as admin-only commands or feature-flagged responses—without modifying the core handler logic.

Summary

  • Predicate-based routing in Telego uses boolean functions (func(context.Context, telego.Update) bool) to filter incoming updates, defined in telegohandler/predicates.go.
  • The route struct in telegohandler/handler_group.go aggregates predicates and associates them with handlers, middleware, or sub-groups.
  • Context.Next in telegohandler/context.go implements a depth-first traversal with backtracking, evaluating all predicates in a route via route.match before executing handlers.
  • Built-in predicates cover message types, commands, text content, callback queries, and logical operations (And, Or, Not), all composable through the HandlerGroup API.
  • Custom predicates implement the same function signature, enabling domain-specific routing logic while maintaining type safety.

Frequently Asked Questions

How does Telego handle multiple predicates in a single route?

When multiple predicates are passed to Handle() or Group(), Telego evaluates them as a logical AND operation. In telegohandler/handler_group.go, the route.match method iterates through all predicates in the slice and returns false immediately if any predicate returns false. The update is cloned before evaluation to prevent mutation by individual predicates.

What is the difference between Handle and Group in Telego?

Handle registers a terminal handler function that executes when all predicates match, storing it in a route struct with the handler field set. Group creates a nested HandlerGroup that can contain its own routes, middleware, and sub-groups, enabling hierarchical routing trees. Groups are useful for applying common predicates (like admin checks) to multiple handlers without repetition.

How does Telego predicate-based routing compare to regex-based routing?

Telego predicates are type-safe boolean functions that inspect struct fields directly, offering compile-time safety and better performance than string parsing. Regex-based routing typically matches against raw update text or JSON strings, which is flexible but error-prone and slower for complex conditions. Telego's approach allows logical composition (And, Or, Not) and direct access to Telegram API types like Message, CallbackQuery, and Chat.

Can predicates modify the update during routing?

No, predicates receive a clone of the update, not the original. In telegohandler/handler_group.go, the route.match method calls update.Clone() before iterating through predicates. This design prevents side effects where one predicate might mutate the update (e.g., trimming text) and affect subsequent predicates or the final handler. The original update remains immutable during the routing phase.

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 →