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
- Swift 6.2
- macOS 15+
- swift-telegram-bot 4.5+
// 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"),
]
)
]
)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()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 TelerouteExampleThe executable entry point lives at TelerouteExampleApp. Detailed architecture and route documentation live at TelerouteExample README.
command("start")matches/startgroup("admin").command("ban")matches/admin_bancallback("orders/{id}/approve")matches callback data likeorders/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
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
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)")
}
}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.
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()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])Use collections to split large bots by feature.
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())
}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())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())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")
}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)")
}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:
.globalserializes the command across the whole bot.chatserializes perchatId.chatUserserializes perchatId + 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")
}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()
)TelerouteContext exposes convenience helpers:
reply(text:)send(text:to:)edit(text:)answerCallbackQuery(text:)
It also exposes parsed values:
commandparametersmessagecallbackQuerycallbackDatachatIduserIdactiveFlow
- 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
- 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
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
)