Teleroute is a route-style application layer for swift-telegram-bot. Its architecture follows the same useful separation as Hummingbird:
Teleroutebuilds a bot-independent route graph;TelerouteBotowns the bot, configuration, lifecycle, and update runtime;TelerouteRouterGroupcomposes route namespaces, middleware, guards, and contexts;TelerouteRouteCollectionpackages controllers/features for reuse;TelerouteResponsedescribes common Telegram actions declaratively.
Teleroute also provides typed commands and callbacks, scope-bound keyboards, flows, command queues, replay protection, events, metrics, and in-process tests.
- Swift 6.3
- macOS 15+
- swift-telegram-bot 10.0+
// swift-tools-version: 6.3
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"),
// Add only when this target uses @TelerouteCommand or
// @TelerouteCallback.
// .product(name: "TelerouteMacros", package: "Teleroute"),
]
),
.testTarget(
name: "MyBotTests",
dependencies: [
"MyBot",
.product(name: "TelerouteTestSupport", package: "Teleroute"),
]
),
]
)The core runtime does not load the macro compiler plugin. Import
TelerouteMacros only in targets that declare macro-annotated route types.
import Logging
import Teleroute
let router = Teleroute()
router.command("ping", description: "Check bot health") { _ in
.reply("pong")
}
router.callback("orders/{id}/approve") { context in
let id = try context.parameters.require("id")
return .sequence([
.answerCallback("Approved \(id)"),
.edit("Order \(id) approved"),
])
}
let telegramBot = try await TGBot(
connectionType: .longpolling(),
tgClient: TGClientDefault(),
botId: "<token>",
log: Logger(label: "telegram.bot")
)
let bot = TelerouteBot(
bot: telegramBot,
router: router,
logger: Logger(label: "telegram.teleroute"),
configuration: .init(syncPublishedCommandsOnStart: true)
)
try await bot.run()bot.run() attaches the internal dispatcher, optionally synchronizes command
menus, starts the bot, waits until cancellation, and then shuts down gracefully.
There is no separate bot.add(router:) or public router.attach() step.
For handlers that need arbitrary Telegram operations, use the explicitly named side-effect API:
router.onCommand("report") { context in
try await context.bot.sendDocument(/* ... */)
try await context.reply("Report sent")
}command and callback return TelerouteResponse; onCommand and
onCallback return Void. Keeping the two forms separate avoids ambiguous
Swift overload resolution.
All runtime dependencies and policies live in one value:
let bot = TelerouteBot(
bot: telegramBot,
router: router,
logger: logger,
configuration: .init(
flowStorage: DatabaseFlowStorage(),
replayProtectionStorage: RedisReplayProtectionStorage(),
replayProtectionTTL: .seconds(5),
maximumConcurrentUpdates: 64,
flowCancellationPolicy: .manual,
metricsSink: MetricsSink(),
onError: { error, context in
try? await context.reply("Something went wrong")
},
syncPublishedCommandsOnStart: true
)
)Pass replayProtectionStorage: nil to disable replay protection.
maximumConcurrentUpdates defaults to 64; producers suspend when all slots
are occupied instead of creating an unbounded number of tasks.
For embedding and tests, the lifecycle can also be controlled explicitly:
try await bot.attach() // no polling/webhook start
await bot.process(updates) // synthetic or externally supplied updates
try await bot.start() // idempotent
await bot.shutdown() // idempotentA TelerouteBot cannot be restarted after shutdown.
Commands use Telegram-compatible _-joined group names. Callbacks retain /
hierarchy:
let admin = router.group("admin")
admin.command("status") { _ in
.reply("ok")
}
admin.callback("users/{id}/ban") { context in
.answerCallback("Banned \(try context.parameters.require("id"))")
}These match /admin_status and admin/users/42/ban.
Middleware and guards can be attached once to a router or group:
router.middlewares.add(TelerouteAccessLogMiddleware(label: "telegram.routes"))
let admin = router.group("admin")
admin.middlewares.add(TelerouteTimeoutMiddleware(.seconds(3)))
admin.guards.add(TelerouteAdminGuard())
admin.command("status") { _ in .reply("ok") }They are snapshotted and compiled when a route or child group is registered, so add them before the routes they should affect. Route-local low-level middleware and guards remain available:
router.command(
"slow",
guards: [TeleroutePrivateChatGuard()],
middlewares: [TelerouteRetryMiddleware(retries: 2)]
) { _ in
.reply("done")
}Typed commands decode command arguments and keep parsing out of handlers.
struct BanCommand: TelerouteCommand {
static let path = "ban"
static let commandDescription = "Ban a user"
static let visibility: [TelerouteCommandVisibility] = [.allChatAdministrators]
let userID: String
let reason: String?
init(command: TelerouteCommandMatch) throws {
self.userID = try command.require("userID", at: 0)
self.reason = command.get("reason", at: 1)
}
}
router.command(BanCommand.self) { command, _ in
.reply("Ban \(command.userID): \(command.reason ?? "no reason")")
}/ban 42 spam produces BanCommand(userID: "42", reason: "spam").
For a side-effect controller method:
router.onCommand(BanCommand.self, use: moderationController.ban)import Teleroute
import TelerouteMacros
@TelerouteCommand("ban")
struct BanCommand {
let userID: String
let reason: String?
}The macro synthesizes TelerouteCommand, path, positional decoding in stored
property order, and a memberwise initializer. Required properties throw when
missing; optional properties decode as nil.
Registration is identical:
router.command(BanCommand.self) { command, _ in
.reply("Ban \(command.userID)")
}Data-only commands plus explicit controllers are the dependency-friendly
default. Small commands may opt into TelerouteHandlingCommand:
struct HealthCommand: TelerouteHandlingCommand {
static let path = "health"
init(command: TelerouteCommandMatch) throws {}
func handle(context: TelerouteContext) async throws {
try await context.reply("ok")
}
}
router.command(HealthCommand.self)Self-handling routes always receive the core TelerouteContext, including when
the router uses a custom request context.
Typed callback registration returns a scope-bound route handle. The same handle registers the handler, builds callback data, and creates buttons, so callback path strings do not spread through the project.
struct RejectOrder: TelerouteCallback {
static let path = "orders/{orderID}/reject"
let orderID: String
init(orderID: String) {
self.orderID = orderID
}
init(parameters: TelerouteParameters) throws {
self.orderID = try parameters.require("orderID")
}
var parameters: [String: String] {
["orderID": self.orderID]
}
}
let rejectOrder = router.callback(RejectOrder.self) { callback, _ in
.sequence([
.answerCallback("Rejected \(callback.orderID)"),
.edit("Order \(callback.orderID) rejected"),
])
}import TelerouteMacros
@TelerouteCallback("orders/{orderID}/reject")
struct RejectOrder {
let orderID: String
}
let rejectOrder = router.callback(RejectOrder.self) { callback, _ in
.answerCallback("Rejected \(callback.orderID)")
}Every {placeholder} must map one-to-one to a required stored String
property. The macro synthesizes decoding, parameters, and a memberwise
initializer.
let keyboard = try router.keyboard([[
rejectOrder.button(
RejectOrder(orderID: "42"),
"Reject #42",
style: "danger"
)
]])
router.command("orders") { _ in
.reply(
"Orders",
replyMarkup: .inlineKeyboardMarkup(keyboard)
)
}Use rejectOrder.callbackData(for:) when an encoded string is required by an
external Telegram API. callback.button(...) is also available, but is
validated against the scope that renders it. Route-bound buttons are safer for
cross-feature composition.
Pagination keeps the same route binding:
let row = TeleroutePagination.navigationRow(
pageRoute,
page: page,
pageCount: pageCount
) { PageCallback(page: $0) }TelerouteRouteCollection is the feature-composition boundary. A collection
can own dependencies and return only the callback handles needed by its parent:
struct OrderRoutes: TelerouteRouteCollection {
struct Exports: Sendable {
let reject: TelerouteCallbackRoute<RejectOrder>
}
let controller: OrderController
func addRoutes(
to routes: TelerouteRouterGroup<TelerouteContext>
) -> Exports {
routes.onCommand("orders", use: controller.list)
let reject = routes.onCallback(
RejectOrder.self,
use: controller.reject
)
return .init(reject: reject)
}
}
let orders = router.group("shop").addRoutes(
OrderRoutes(controller: orderController)
)
let button = orders.reject.button(
RejectOrder(orderID: "42"),
"Reject"
)This produces /shop_orders and shop/orders/42/reject while the parent never
duplicates either route path.
The default router uses TelerouteContext:
let router = Teleroute()A custom context can carry request-scoped state and application services:
struct AppContext: TelerouteInitializableRequestContext {
let coreContext: TelerouteContext
var requestID: String?
init(source: TelerouteContextSource) {
self.coreContext = source.coreContext
}
}
let router = Teleroute(context: AppContext.self)If construction needs captured dependencies, provide a factory:
struct AppContext: TelerouteRequestContext {
let coreContext: TelerouteContext
let orders: OrderService
}
let router = Teleroute(context: AppContext.self) { source in
AppContext(coreContext: source.coreContext, orders: orderService)
}Common Telegram properties and methods (update, bot, parameters,
command, message, chatId, userId, reply, send, edit, flow
helpers, and others) are forwarded from coreContext.
TelerouteRouterMiddleware can transform a custom context and return a
response, including a short-circuit response:
struct RequestIDMiddleware: TelerouteRouterMiddleware {
typealias Context = AppContext
func handle(
_ context: AppContext,
next: @escaping @Sendable (AppContext) async throws -> TelerouteResponse
) async throws -> TelerouteResponse {
var context = context
context.requestID = UUID().uuidString
return try await next(context)
}
}
router.middlewares.add(RequestIDMiddleware())Low-level TelerouteMiddleware continues to wrap TelerouteContext and powers
the built-in timeout, retry, logging, throttle, debounce, queue, and
error-handling middleware.
A child context narrows guarantees for a nested group:
struct AuthenticatedContext: TelerouteChildRequestContext {
typealias ParentContext = AppContext
let coreContext: TelerouteContext
let userID: Int64
init(context: AppContext) throws {
guard let userID = context.userId else {
throw AuthenticationError.userMissing
}
self.coreContext = context.coreContext
self.userID = userID
}
}
router.group("account", context: AuthenticatedContext.self) { account in
account.command("me") { context in
.reply("User \(context.userID)")
}
}Parent typed middleware runs before the child context is constructed. Child contexts and middleware may be chained through multiple nested groups.
TelerouteResponse supports:
.none
.reply("text", parseMode: nil, replyMarkup: nil)
.send("text", to: chatID)
.edit("new text", replyMarkup: keyboard)
.answerCallback("done", showAlert: false)
.sequence([.answerCallback(), .edit("done")])Responses run in sequence and use the matched core context. Use onCommand or
onCallback when a handler needs operations not represented by this enum.
Built-in guards:
TeleroutePrivateChatGuardTelerouteGroupChatGuardTelerouteChatTypeGuardTelerouteUserAllowlistGuardTelerouteChatAllowlistGuardTelerouteArgumentCountGuardTelerouteAdminGuard
TelerouteAdminGuard calls Telegram getChatMember; it is not a local-only
predicate.
Built-in middleware:
TelerouteAccessLogMiddlewareTelerouteTimeoutMiddlewareTelerouteRetryMiddlewareTelerouteErrorHandlingMiddlewareTelerouteThrottleMiddlewareTelerouteDebounceMiddleware
Low-level middleware intentionally consuming an update without calling next
is handled by Teleroute's internal consuming semantics, so fallback routes do
not run for throttled or superseded updates.
Commands can serialize work by scope:
router.command("rebuild", queue: .global) { _ in .reply("done") }
router.command("export", queue: .perChat) { _ in .reply("done") }
router.command("profile", queue: .perChatAndUser) { _ in .reply("done") }Typed commands may declare a default queue through TelerouteCommand.queue.
Flows route messages, commands, and callbacks according to one active session
per chatId + userId:
struct SignupFlow: TelerouteFlow {
enum Step: String, Sendable {
case name
case confirm
}
func boot(flow: TelerouteFlowGroup<Self>) {
flow.start(
"signup",
at: .name,
description: "Start signup"
) { context in
try await context.reply("What is your name?")
}
flow.message(at: .name) { context in
let name = context.message?.text ?? ""
try await context.transition(to: .confirm, merging: ["name": name])
try await context.reply("Confirm \(name)?")
}
flow.command("cancel", at: .confirm) { context in
try await context.finish()
try await context.reply("Cancelled")
}
}
}
router.flow(SignupFlow())Active flow routes run before regular callbacks and commands. Regular callbacks
run before regular commands. Flow step handlers use TelerouteFlowContext;
feature dependencies can be captured by the flow value itself.
Unmatched-command behavior is controlled by:
.cancelOnAnyUnmatchedCommand(default).preserveOnUnmatchedCommand.manual
Descriptions registered on routes are grouped by Telegram visibility:
router.command(
"help",
description: "Show help",
visibility: [.allPrivateChats]
) { _ in .reply("Help") }
for commandSet in try router.publishedCommandSets() {
print(commandSet.visibility, commandSet.commands)
}Set syncPublishedCommandsOnStart: true to publish registered menus from
bot.start()/bot.run(), or control them explicitly:
try await bot.syncPublishedCommands()
try await bot.publishCommands(
[("health", "Check health")],
visibility: .allChatAdministrators
)
try await bot.publishCommands([BanCommand.self])Handlers may update a chat/member-specific menu through
context.coreContext.publishCommands(...).
Every subscriber gets an independent event stream:
let events = bot.eventStream(buffering: .newest(256))
Task {
for await event in events {
print(event.kind, event.routeKind, event.routeName ?? "-")
}
}Events are emitted for received, skippedDuplicate, handled, unmatched,
and failed. Failed events retain the typed error and a stable description.
onError, logging, events, and TelerouteMetricsSink observe the same routing
outcome.
await bot.shutdown() stops accepting updates, cancels in-flight handlers,
finishes event streams, and stops a started Telegram connection.
TelerouteContext and custom request contexts expose text, callback, message,
flow, and identity helpers. TelerouteContext additionally includes media,
forwarding, deletion, editing, and chat-action helpers.
router.onCommand("photo") { context in
try await context.coreContext.sendPhoto(/* ... */)
try await context.reply("Photo sent")
}The raw TGUpdate remains available as context.update, and the bot escape
hatch remains available as context.bot.
For every update, Teleroute creates one internal parsed update and takes one immutable route-graph snapshot. Command routes use a name index, callback routes use a compiled component index, and flow routes use a flow/step index.
Order is deterministic:
- active flow routes;
- regular callbacks;
- regular commands;
- first registered route whose guards/middleware reach the handler wins.
Callback generation and matching share the same percent-encoding rules.
Duplicate unguarded route signatures are exposed through
router.duplicateRouteSignatures; guarded duplicates are allowed because they
can intentionally select different handlers.
Run the release benchmark with:
swift run -c release TelerouteBenchmarksTelerouteTestSupport creates a TelerouteBot backed by a recording Telegram
client. No polling, webhook server, or network request is started:
import Testing
import Teleroute
import TelerouteTestSupport
@Test
func startCommand() async throws {
let router = Teleroute()
router.command("start") { _ in .reply("Welcome") }
let (bot, telegram) = try await TelerouteTestSupport.makeTelerouteBot(
router: router
)
try await bot.test { client in
let result = await client.sendCommand("start")
#expect(result.terminalEvent?.kind == .handled)
}
guard case let .some(.sentMessage(message)) = telegram.effects.first else {
Issue.record("Expected a message")
return
}
#expect(message.text == "Welcome")
}The recording client captures sent text and reply markup, message edits,
callback answers, and command-menu updates. The test client also provides
sendMessage, pressCallback, and raw execute(TGUpdate) methods.
Sources/TelerouteExample is a runnable bot showing:
Teleroute/TelerouteBotbootstrap and automatic command synchronization;- custom and child request contexts;
- typed context middleware and group middleware/guards;
- route collections with exported callback handles;
- commands/callbacks with and without macros;
- declarative responses and direct side effects;
- typed keyboards, flows, and scoped command menus.
Run it with:
TELEGRAM_BOT_TOKEN=<token> swift run TelerouteExampleThe repository includes a Teleroute Codex skill under skills/teleroute.
pfw install ./skills/teleroute