Skip to content

tixster/Teleroute

Repository files navigation

Teleroute

Linux CI License: MIT Swift 6.2

Teleroute is a route-style layer for swift-telegram-bot.

It gives Telegram bots API for:

  • commands
  • callback queries
  • route groups
  • typed command and callback specs
  • collections
  • guards and middleware
  • stateful multi-step flows

Requirements

Installation

// swift-tools-version: 6.2
import PackageDescription

let package = Package(
    name: "MyBot",
    dependencies: [
        .package(url: "https://github.com/tixster/Teleroute.git", from: "1.0.0"),
    ],
    targets: [
        .executableTarget(
            name: "MyBot",
            dependencies: [
                .product(name: "Teleroute", package: "Teleroute"),
            ]
        )
    ]
)

Quick Start

import Logging
import Teleroute

let bot = try await TGBot(
    connectionType: .longpolling(),
    tgClient: TGClientDefault(),
    botId: "<token>",
    log: Logger(label: "telegram.bot")
)

let router = Teleroute(
    bot: bot,
    logger: Logger(label: "telegram.router")
)

router.command("ping") { _, context in
    try await context.reply(text: "pong")
}

router.callback("orders/{id}/approve") { _, context in
    let id = try context.parameters.require("id")
    try await context.answerCallbackQuery(text: "approved \(id)")
}

try await bot.add(router: router)
try await bot.start()

Example Project

The package includes a runnable example target that exercises the full feature set:

  • string commands and callbacks
  • typed commands and typed callbacks
  • route groups
  • collections and group-owned collections
  • guards and middleware
  • command queueing
  • published command sync
  • multi-step flows

Run it with a real Telegram bot token:

TELEGRAM_BOT_TOKEN=123456:abc swift run TelerouteExample

The executable entry point lives at TelerouteExampleApp. Detailed architecture and route documentation live at TelerouteExample README.

Core Ideas

  • command("start") matches /start
  • group("admin").command("ban") matches /admin_ban
  • callback("orders/{id}/approve") matches callback data like orders/42/approve
  • callback path parameters are encoded on generation and decoded on match
  • callbacks are matched before commands
  • active flow handlers are matched before regular routes

Commands vs Callbacks

command(...) handles Telegram messages that start with /.

router.command("start") { _, context in
    try await context.reply(text: "hello")
}

This handler runs for a message like:

/start

callback(...) handles inline keyboard button presses.

router.callback("users/{id}/ban") { _, context in
    let id = try context.parameters.require("id")
    try await context.answerCallbackQuery(text: "banned \(id)")
}

This handler does not run from a chat message. It runs when a user presses an inline button whose callback_data matches the route.

For example:

let button = try router.callbackButton(
    "Ban",
    path: "users/{id}/ban",
    parameters: ["id": "42"]
)

This produces callback data like:

users/42/ban

If the route is inside a group:

router.group("admin") { admin in
    admin.callback("users/{id}/ban") { _, context in
        let id = try context.parameters.require("id")
        try await context.answerCallbackQuery(text: "banned \(id)")
    }

    let groupedButton = try admin.callbackButton(
        "Ban",
        path: "users/{id}/ban",
        parameters: ["id": "42"]
    )
}

then groupedButton.callbackData becomes:

admin/users/42/ban

because the admin prefix is added by the same group that generated the button.

If you generate the button from the root router, you must pass the full path yourself:

let rootButton = try router.callbackButton(
    "Ban",
    path: "admin/users/{id}/ban",
    parameters: ["id": "42"]
)

Rule of thumb:

  • if you generate the button from router, use the full callback path
  • if you generate the button from a group, use the path relative to that group

Usage Styles

1. String-Based Routes

Use this when you want the smallest API surface and manual parsing is enough.

router.group("admin") { admin in
    admin.command("ban") { _, context in
        let userID = context.command?.arguments.first ?? "unknown"
        try await context.reply(text: "ban \(userID)")
    }

    admin.callback("users/{id}/ban") { _, context in
        let id = try context.parameters.require("id")
        try await context.answerCallbackQuery(text: "banned \(id)")
    }
}

2. Typed Commands And Callbacks

Use this when you want parsing and route-specific logic to live in dedicated types.

struct BanCommand: TelerouteCommand {
    static let path = "ban"
    static let commandDescription: String? = "Ban a user"
    static let visibility: [TelerouteCommandVisibility] = [.allGroupChats]

    let userID: String

    init(command: TelerouteCommandMatch) throws {
        self.userID = try command.require("userID")
    }

    func handle(update: TGUpdate, context: TelerouteContext) async throws {
        try await context.reply(text: "ban \(self.userID)")
    }
}

struct ApproveOrderCallback: TelerouteCallback {
    static let path = "orders/{id}/approve"

    let id: String

    init(id: String) {
        self.id = id
    }

    init(parameters: TelerouteParameters) throws {
        self.id = try parameters.require("id")
    }

    var parameters: [String: String] {
        ["id": self.id]
    }

    func handle(update: TGUpdate, context: TelerouteContext) async throws {
        try await context.answerCallbackQuery(text: "approved \(self.id)")
    }
}

router.command(BanCommand.self)
router.callback(ApproveOrderCallback.self)

If you want typed parsing but prefer handlers outside the type:

router.command(BanCommand.self) { _, context, command in
    try await context.reply(text: "ban \(command.userID)")
}

router.callback(ApproveOrderCallback.self) { _, context, callback in
    try await context.answerCallbackQuery(text: "approved \(callback.id)")
}

Typed callbacks also work inside groups:

struct BanUserCallback: TelerouteCallback {
    static let path = "users/{id}/ban"

    let id: String

    init(id: String) {
        self.id = id
    }

    init(parameters: TelerouteParameters) throws {
        self.id = try parameters.require("id")
    }

    var parameters: [String: String] {
        ["id": self.id]
    }
}

let admin = router.group("admin")
admin.callback(BanUserCallback.self)

let button = try admin.callbackButton(
    "Ban",
    callback: BanUserCallback(id: "42")
)

Here button.callbackData becomes admin/users/42/ban because the callback is generated from the same group.

3. Published Commands And Visibility

Use this when you want Teleroute to register Telegram command menus through setMyCommands.

router.command(
    "start",
    description: "Start the bot",
    visibility: [.allPrivateChats]
) { _, context in
    try await context.reply(text: "hello")
}

router.command(
    "ban",
    description: "Ban a user",
    visibility: [.allGroupChats, .allChatAdministrators]
) { _, context in
    try await context.reply(text: "ban")
}

try await router.syncPublishedCommands()

If you need to replace the visible command list dynamically at runtime, you can publish commands directly:

try await router.publishCommands(
    [("profile", "Open profile"), ("logout", "Log out")],
    visibility: .allPrivateChats
)

The same helper is available inside handlers:

router.command("start") { _, context in
    try await context.publishCommands(
        [("profile", "Open profile"), ("logout", "Log out")],
        visibility: .chat(.id(1))
    )
}

Typed commands are also supported:

try await router.publishCommands([StartCommand.self])

router.command("login") { _, context in
    try await context.publishCommands(
        [StartCommand.self],
        visibility: .chat(.id(1))
    )
}

Available visibility helpers:

  • .default - base Telegram command scope used when no narrower scope matches
  • .allPrivateChats - visible in all private chats with the bot
  • .allGroupChats - visible in all group and supergroup chats
  • .allChatAdministrators - visible to administrators in all group and supergroup chats
  • .chat(.id(123)) - visible only in one specific chat by numeric chat id
  • .chat(.username("@my_group")) - visible only in one specific chat by public username
  • .chatAdministrators(...) - visible only to administrators of one specific group or supergroup
  • .chatMember(..., userID: ...) - visible only to one specific user inside one specific group or supergroup

Telegram resolves command lists from the narrowest scope to the broadest one, and falls back to .default when there is no more specific match.

Typed commands can declare the same metadata on the spec:

struct StartCommand: TelerouteCommand {
    static let path = "start"
    static let commandDescription: String? = "Start the bot"
    static let visibility: [TelerouteCommandVisibility] = [.allPrivateChats]

    init(command: TelerouteCommandMatch) throws {}
}

router.command(StartCommand.self)
try await router.syncPublishedCommands()

4. Callback Buttons From Route Definitions

Use the same callback definitions for routing and keyboard generation.

String-based:

let row = try router.callbackButtons([
    ("Approve", path: "orders/{id}/approve", parameters: ["id": "42"]),
    ("Reject", path: "orders/{id}/reject", parameters: ["id": "42"]),
])

let keyboard = router.callbackKeyboard([row])

Typed:

struct RejectOrderCallback: TelerouteCallback {
    static let path = "orders/{id}/reject"

    let id: String

    init(id: String) {
        self.id = id
    }

    init(parameters: TelerouteParameters) throws {
        self.id = try parameters.require("id")
    }

    var parameters: [String: String] {
        ["id": self.id]
    }

    func handle(update: TGUpdate, context: TelerouteContext) async throws {
        try await context.answerCallbackQuery(text: "rejected \(self.id)")
    }
}

router.callback(RejectOrderCallback.self)

let row = try router.callbackButtons([
    ("Approve", ApproveOrderCallback(id: "42")),
    ("Reject", RejectOrderCallback(id: "42")),
])

let keyboard = router.callbackKeyboard([row])

5. Collections

Use collections to split large bots by feature.

Mount Into An Existing Group

Use TelerouteCollection when the group path is chosen by the caller.

struct AdminRoutes: TelerouteCollection {
    func boot(routes: TelerouteGroup) {
        routes.command("ban") { _, context in
            try await context.reply(text: "ban")
        }
    }
}

router.group("admin") { admin in
    admin.add(collection: AdminRoutes())
}

Let The Collection Own Its Prefix

Use TelerouteGroupCollection when the collection should be fully self-contained.

struct AdminRoutes: TelerouteGroupCollection {
    let path = "admin"

    func boot(collection: TelerouteCollectionGroup) {
        collection.command("ban") { _, context in
            try await context.reply(text: "ban")
        }
    }
}

router.group(AdminRoutes())

Use A Collection Builder API

If you want collection code to avoid exposing TelerouteGroup, conform to TelerouteCollectionBuilder.

struct ProfileRoutes: TelerouteCollectionBuilder {
    func boot(collection: TelerouteCollectionGroup) {
        collection.command("me") { _, context in
            try await context.reply(text: "profile")
        }
    }
}

router.add(collection: ProfileRoutes())

6. Guards

Use guards to select routes by context without putting if logic into handlers.

struct PrivateChatGuard: TelerouteGuard {
    func matches(_ context: TelerouteContext) async throws -> Bool {
        context.message?.chat.type == .private
    }
}

router.command("start", routeGuard: PrivateChatGuard()) { _, context in
    try await context.reply(text: "private only")
}

7. Middleware

Use middleware for logging, auth, metrics, or shared pre/post hooks.

struct LoggingMiddleware: TelerouteMiddleware {
    func handle(
        _ context: TelerouteContext,
        next: @escaping @Sendable (TelerouteContext) async throws -> Void
    ) async throws {
        print("before")
        try await next(context)
        print("after")
    }
}

router.command(
    "start",
    middlewares: [LoggingMiddleware()]
) { _, context in
    try await context.reply(text: "hello")
}

Guards and middleware can be combined:

router.callback(
    "orders/{id}/approve",
    routeGuard: PrivateChatGuard(),
    middlewares: [LoggingMiddleware()]
) { _, context in
    let id = try context.parameters.require("id")
    try await context.answerCallbackQuery(text: "approved \(id)")
}

8. Command Queueing

Use queueing: when a command must run sequentially instead of being handled in parallel.

router.command("sync_orders", queueing: .chatUser) { _, context in
    try await context.reply(text: "sync started")
    // long-running work
}

Available strategies:

  • .global serializes the command across the whole bot
  • .chat serializes per chatId
  • .chatUser serializes per chatId + userId
Strategy Serializes by Use when
.global one shared queue for the command across the whole bot the command touches shared global state or an external job that must never overlap
.chat one queue per Telegram chat the command should not overlap inside the same group/private chat, but different chats may run in parallel
.chatUser one queue per chatId + userId pair only the same user should be serialized, while other users in the same chat may still run the command

Only commands where you explicitly pass queueing: are queued. All other commands keep the default behavior.

Typed commands can declare their default strategy on the spec itself:

struct SyncOrdersCommand: TelerouteCommand {
    static let path = "sync_orders"
    static let queueing: TelerouteCommandQueueing? = .chatUser

    init(command: TelerouteCommandMatch) throws {}
}

router.command(SyncOrdersCommand.self)

If you also pass queueing: during registration, that explicit value overrides the one declared on the spec.

This also works for flow entry commands:

flow.start("signup", at: .name, queueing: .chatUser) { _, context in
    try await context.reply(text: "Send your name")
}

9. Flows

Use flows for multi-step stateful interactions.

Each active flow keeps one session scoped by chatId + userId.

struct SignupFlow: TelerouteFlow {
    enum Step: String, Sendable {
        case name
        case confirm
    }

    func boot(flow: TelerouteFlowGroup<SignupFlow>) {
        flow.start("signup", at: .name) { _, context in
            try await context.reply(text: "Send your name")
        }

        flow.message(at: .name) { _, context in
            let name = context.message?.text ?? ""

            let buttons = try flow.callbackButtons([
                ("Approve", path: "confirm/{decision}", parameters: ["decision": "approve"]),
                ("Reject", path: "confirm/{decision}", parameters: ["decision": "reject"]),
            ])

            try await context.transition(to: .confirm, merging: ["name": name])
            try await context.reply(
                text: "Confirm signup for \(name)?",
                replyMarkup: flow.callbackKeyboard([buttons])
            )
        }

        flow.callback("confirm/{decision}", at: .confirm) { _, context in
            let name = try context.values.require("name")
            let decision = try context.parameters.require("decision")

            try await context.finish()
            try await context.reply(text: "\(name): \(decision)")
        }
    }
}

router.add(flow: SignupFlow())

If a user sends any Telegram command while a flow is active, the current flow session is cancelled first and the command is then handled by regular command routes.

By default Teleroute uses TelerouteInMemoryFlowStorage, but you can inject your own storage:

actor RedisBackedFlowStorage: TelerouteFlowStorage {
    func session(for key: TelerouteFlowKey) async -> TelerouteFlowSession? {
        // load from Redis, database, etc.
        nil
    }

    func setSession(_ session: TelerouteFlowSession, for key: TelerouteFlowKey) async {
        // persist
    }

    func removeSession(for key: TelerouteFlowKey) async {
        // delete
    }
}

let router = Teleroute(
    bot: bot,
    logger: Logger(label: "telegram.router"),
    flowStorage: RedisBackedFlowStorage()
)

Routing Helpers

TelerouteContext exposes convenience helpers:

  • reply(text:)
  • send(text:to:)
  • edit(text:)
  • answerCallbackQuery(text:)

It also exposes parsed values:

  • command
  • parameters
  • message
  • callbackQuery
  • callbackData
  • chatId
  • userId
  • activeFlow

Matching Rules

  • active flow handlers are checked first
  • regular callbacks are checked before regular commands
  • if several routes share the same command or callback pattern, they are evaluated in registration order
  • the first route whose guard and middleware chain reaches the final handler wins
  • repeated identical commands and callback presses from the same chat/user are ignored for 2 seconds by default

Design Notes

  • grouped commands use _ because Telegram commands do not support / hierarchy
  • callback routes keep / hierarchy and support {parameter} placeholders
  • collections are for feature composition
  • flows are for stateful user interaction
  • typed specs are for keeping parsing close to the route domain model

Replay Protection

By default Teleroute suppresses repeated handling of the same command or callback for the same chatId + userId pair during a short window.

The default configuration uses TelerouteInMemoryReplayProtectionStorage with a 2-second TTL.

You can customize or disable it:

actor RedisReplayProtectionStorage: TelerouteReplayProtectionStorage {
    func claim(key: String, ttl: Duration) async -> Bool {
        true
    }
}

let router = Teleroute(
    bot: bot,
    logger: Logger(label: "telegram.router"),
    flowStorage: TelerouteInMemoryFlowStorage(),
    replayProtectionStorage: RedisReplayProtectionStorage(),
    replayProtectionTTL: .seconds(5)
)

To disable replay protection entirely:

let router = Teleroute(
    bot: bot,
    logger: Logger(label: "telegram.router"),
    flowStorage: TelerouteInMemoryFlowStorage(),
    replayProtectionStorage: nil
)