type-safe kotlin toolkit for building Telegram bots, wizard-flows, and other interactive systems powered by FSM
🧩 state + input → newState + effects
Add the Reposilite snapshot repository and telek dependencies:
repositories {
mavenCentral()
maven {
name = "reposiliteRepositorySnapshots"
url = uri("https://reposilite.kotlin.website/snapshots")
}
// ONLY IF YOU USE `telegram` OR `router-telegram`. kotlin-telegram-bot is published on JitPack
// and not on Maven Central, so without this the build fails with "Could not find
// io.github.kotlin-telegram-bot.kotlin-telegram-bot:telegram" — an error that names somebody
// else's library and gives you no reason to suspect this page. `ktg` needs none of it:
// ktgbotapi is on Maven Central, which is one more reason it is the transport.
//
// Filtered to the group it answers for. An unfiltered repository takes part in resolving every
// dependency, and when it is unreachable Gradle disables it and fails artifacts that were
// perfectly fine.
maven("https://jitpack.io") {
content { includeGroupByRegex("io\\.github\\.kotlin-telegram-bot.*") }
}
}
dependencies {
implementation("io.github.youndie.telek:core:<VERSION>")
// the transport:
implementation("io.github.youndie.telek:ktg:<VERSION>") // ktgbotapi
// implementation("io.github.youndie.telek:telegram:<VERSION>") // kotlin-telegram-bot, maintenance
}The core module contains the FSM engine, transitions, and effect system. The ktg module is the
integration on ktgbotapi, and it is the
transport: it is multiplatform, so it is the one a native binary can use, and it is where new work
lands.
telegram, on kotlin-telegram-bot, is
in maintenance — the same API shape, still built, still tested, still published, and not
deprecated. What maintenance means here is narrow and worth saying exactly: it does not get new
input types or other additions to the model unless somebody asks for them. Use it if your bot
already runs on kotlin-telegram-bot; reach for ktg otherwise.
Multiplatform. core, ktg, router, router-ktg, persistence and testing are Kotlin
Multiplatform, published for JVM, linuxX64 and linuxArm64 — so a bot can also ship as a native
Linux binary. telegram and router-telegram are JVM-only, because kotlin-telegram-bot is, and
that is the concrete half of the sentence above: those two modules are the ones a native binary
cannot contain. A plain JVM Gradle project resolves the right variant automatically from Gradle
module metadata; nothing changes for JVM consumers.
telek integrates with ktgbotapi through
:ktg, the transport this example is written against. Each StateDispatcher describes one
conversational flow — for example, a multistep wizard
Below is a simple dispatcher handling a confirmation dialog. The two buttons are routes — a route is a type, so the compiler keeps the button and the branch that handles it in step; rename one and the other stops compiling. See Router module for what else routes carry.
// The two buttons this flow can produce
@RouteContext(scope = "example", action = "confirm")
@Serializable
class ExampleConfirm : Route
@RouteContext(scope = "example", action = "cancel")
@Serializable
class ExampleCancel : Route
val exampleRoutes = routes {
register<ExampleConfirm>()
register<ExampleCancel>()
}
// Dispatcher that manages the conversation flow (FSM) for the 'example' command
class ExampleDispatcher : StateDispatcher<ExampleState>() {
// The command that starts this dispatcher flow
override val startCommand = "example"
// The associated state class for this flow
override val stateClass = ExampleState::class
// Handles finite-state transitions based on current state and input
override fun transition(
state: ExampleState,
input: Input,
): TransitionResult<ExampleState> =
when (state) {
// If waiting for a string, and receive a message input from user
is ExampleState.WaitingString if (input is Message) -> {
transition {
// Move to Confirming state, keep number, save input string
newState = ExampleState.Confirming(
number = state.number,
string = input.text,
)
// Send confirmation message with inline keyboard (Confirm/Cancel)
sendMessage(
input.chatId,
message = {
row {
text("Confirm?")
}
},
keyboard = {
row {
callback(name = "Confirm", route = ExampleConfirm())
callback(name = "Cancel", route = ExampleCancel())
}
},
)
}
}
// If in Confirming state and receive a callback from the inline keyboard
is ExampleState.Confirming if (input is Callback) -> {
transition {
// Move to Done state
newState = ExampleState.Done
// Remove inline keyboard from message
editMarkup(input.chatId, input.messageId, null)
// Respond with confirmation or cancellation based on which route it was
if (input.isRouteOf<ExampleConfirm>(exampleRoutes)) {
sendMessage(input.chatId, "confirmed")
} else {
sendMessage(input.chatId, "canceled")
}
}
}
// For all other cases, no state transition
else -> noTransition(state)
}
}And here is the whole of what testing it takes. A transition is a pure function of state and input — no bot, no network, no coroutine:
class ExampleDispatcherTest {
private val dispatcher = ExampleDispatcher()
private fun answer(route: Route) = dispatcher.transition(
state = ExampleState.Confirming(number = 1, string = "hello"),
input = Callback(chatId = 42, messageId = 7, data = RouteUtils.encodeRouteDynamic(route)),
)
@Test
fun `confirming ends the flow and says so`() {
val result = answer(ExampleConfirm())
assertEquals(ExampleState.Done, result.newState)
assertEquals("confirmed", result.effects.filterIsInstance<SendMessageEffect>().single().text)
}
// The state is Done either way, so asserting only on it would pass with the two routes
// swapped. The reply is the only thing that tells them apart.
@Test
fun `cancelling ends the flow and says the other thing`() {
val result = answer(ExampleCancel())
assertEquals(ExampleState.Done, result.newState)
assertEquals("canceled", result.effects.filterIsInstance<SendMessageEffect>().single().text)
}
}This example shows how telek lets you:
- 🧩 Define a finite-state flow per user
- 💬 Send messages and inline keyboards declaratively
- 🔁 Handle message and callback inputs as FSM transitions
- ✨ Keep logic pure and testable — the test above is the whole of it, and it runs in this repository on every build
Below is a minimal setup.
val bot = telegramBot("telegram token")
val contextSource = KtgContextSource(bot)
val telek = Telek(
dispatchers = listOf(ExampleDispatcher()),
effectExecutor = ktgEffectExecutor(contextSource),
)
bot.buildBehaviourWithLongPolling {
connect(telek, contextSource, Keying.PerUserInChat)
}.join()KtgContextSource is what lets the effect executor reach the bot. ktgbotapi hands its TelegramBot
out up front, so KtgContextSource(bot) usually resolves immediately; the deferred form
(KtgContextSource() + provide(bot)) is there for wiring built before the bot exists.
connect() is an extension on ktgbotapi's BehaviourContext. It subscribes text, data callbacks,
photos, documents, contacts and locations, maps them to telek Inputs and keys them per person per
chat — see What a conversation is keyed by. It answers each
handled callback query by default so Telegram stops the client's spinner; pass
answerCallbackQueries = false if a dispatcher answers with its own text or alert. A callback query
with no message attached (an inline-mode one) cannot be keyed and is ignored.
Both EffectExecutor.execute and every EffectHandler.handle are suspend — handlers run on a
dedicated I/O dispatcher, off whatever dispatcher your chats' transitions run on, so a slow Telegram
API call for one chat never blocks another chat's turn.
The heading above used to read "Using ktgbotapi instead", back when
telegramwas the transport andktgwas the alternative. That is the other way round now — see Installation — so the section swapped sides. The old anchor is kept above so links to it still land here.
:telegram is the same integration on
kotlin-telegram-bot, and it is in
maintenance: still built, still tested, still published, not deprecated, and not receiving new
input types unless somebody asks. Use it if your bot already runs on kotlin-telegram-bot.
The dispatcher above needs no change — only the effect DSL's package
(io.github.youndie.telek.ktg.* → io.github.youndie.telek.telegram.*) and the wiring:
val contextSource = TelegramContextSource()
val telek = Telek(
dispatchers = listOf(ExampleDispatcher()),
effectExecutor = telegramEffectExecutor(contextSource),
)
bot {
token = "telegram token"
dispatch { connect(telek, contextSource, Keying.PerUserInChat) }
}TelegramContextSource resolves lazily rather than up front: bot { } only hands out its Bot
inside a dispatch { } handler, so the executor and connect() share one source that resolves on
the first update.
Differences worth knowing:
- Effect handler interfaces are
TelegramEffectHandler/TelegramAsyncEffectHandler, taking kotlin-telegram-bot'sBot; on:ktgthey areKtgEffectHandler/KtgAsyncEffectHandlertaking ktgbotapi'sTelegramBot, and the effect marker isKtgEffectrather thanTelegramEffect. - kotlin-telegram-bot returns a result type where ktgbotapi throws, so the two transports' built-in
handlers arrive at
EffectFailedby different routes — the outcome a dispatcher sees is the same. :telegramand:router-telegramare JVM-only, because kotlin-telegram-bot is. They are the two modules a native Linux binary cannot contain.- The router equivalent is
router-telegram— the sameRowBuilder.callback(name, route)extension, overio.github.youndie.telek.telegram.RowBuilder.
On :ktg, ContentMessage<TextContent>.asTelekInput() and the other adapters are public, so a bot
wiring its own updates (webhooks, a custom FlowsUpdatesFilter) can reuse the mapping without going
through connect().
Input is what a user sent. Six types ship:
| Type | Carries |
|---|---|
Message |
text |
Callback |
messageId, data — the button that was pressed |
Photo |
messageId, file, caption |
Document |
messageId, file, fileName, mimeType, caption |
Contact |
messageId, phoneNumber, firstName, lastName, userId |
Location |
messageId, latitude, longitude |
A file arrives as a FileRef — fileId, uniqueId, sizeBytes — and not as bytes. telek does not
download anything: that is a request to Telegram, which is an effect, which is where a transport
belongs. What a dispatcher gets is the identifier it needs to ask for the bytes later, without
naming a transport type to do it:
override fun transition(state: Passport, input: Input): TransitionResult<Passport> =
when {
state is Passport.AwaitingScan && input is Photo ->
transition {
newState = Passport.Received(input.file.fileId)
add(SendMessageEffect(input.chatId, "Got it."))
}
else -> noTransition(state)
}Routing. Only a command and a callback carry routing information of their own, because only they can arrive with no state to belong to. Everything else — a photo, a document, a contact, a location — goes to the dispatcher that owns the conversation's current state, which is what a wizard step wants: the step asked for something, and whatever arrived is the answer.
Input is not sealed, and that is the design. A bot that needs something telek does not model
declares its own and feeds it through Telek.onInput; it routes by state like any other non-command
input, and nothing in core has to know it exists:
data class Voice(override val chatId: Long, val file: FileRef) : InputThe same property is what lets telek add a type without breaking your when. The cost of that
freedom is the one thing to know: connect() subscribes exactly the content types telek models, so
a voice message never reaches the FSM until you wire its trigger yourself — the asTelekInput()
adapters are public so that path reuses them.
A state machine has to file each conversation's state under something, and telek files it under a
ConversationKey — not under the chatId a reply is addressed to. In a private chat the
distinction never shows, because the chat has one person in it. In a group it shows at once: keyed
by the chat alone, two members running the same wizard share one state, one worker and one inbox,
and each one's reply advances the other's flow.
ConversationKey.chatAndUser(chatId = -1001234567890, userId = 42) // one person, in one chat
ConversationKey.chat(chatId = -1001234567890) // the whole chat, sharedconnect() derives the key for you, and how it does so is the keying parameter:
connect(telek, contextSource, Keying.PerUserInChat) // one state per person per chat
connect(telek, contextSource, Keying.PerChat) // one state for the whole chatThere is no default, deliberately. PerUserInChat is what a wizard wants and what a group
requires; an update with no identifiable sender (a channel post, an automatic forward) falls back to
the chat. PerChat is for state that genuinely belongs to everyone — a poll, a group game.
The parameter is required because a default decided this silently for a bot that merely upgraded:
the key changed under it, stored state stopped being found, and a bot that also feeds some inputs
through Telek.onInput directly ended up with one conversation split across two keys in the same
process. None of that has a symptom anyone traces back to a parameter they never typed.
chatId on an Input, an Event and every effect is the address, unchanged: it is where the
reply goes, and in a group it is the same number for everybody. A dispatcher goes on writing
sendMessage(input.chatId, ...) and does not deal in keys at all — only the wiring does, which is
why Telek.onInput takes the key as its own parameter rather than reading one off the input.
A bot that needs a key telek does not model — per forum topic, say — builds one itself and calls
telek.onInput(key, input) directly; the input adapters are public for exactly that.
telek lets you extend its behavior with custom effects —
your own side-effects that will be executed during a transition.
Below is an example of creating a custom effect that deletes a Telegram message.
// Define your custom effect
data class CustomEffect(
val chatId: Long,
val messageId: Long,
) : TelegramEffect
// Implement its handler
class CustomEffectHandler : TelegramEffectHandler<CustomEffect> {
override suspend fun handle(
bot: Bot,
effect: CustomEffect,
): EffectResult =
bot
.deleteMessage(ChatId.fromId(effect.chatId), effect.messageId)
.fold({ EffectSuccess }, { error -> EffectFailed(IllegalStateException(error.toString())) })
}
// DSL extension for transitions
fun <S : State> TransitionBuilder<S>.customEffect(
chatId: Long,
messageId: Long,
) {
add(CustomEffect(chatId, messageId))
}Now register it in your EffectRegistry:
val effectRegistry =
defaultEffectRegistry().apply {
register(CustomEffect::class, CustomEffectHandler())
}
val effectExecutor = telegramEffectExecutor(contextSource, effectRegistry)And use it inside a transition:
transition {
customEffect(input.chatId, input.messageId)
}This mechanism allows you to:
- 🧩 Add new side-effects without modifying telek core
- 🔌 Integrate any external actions (e.g., analytics, notifications, cleanup)
- 🧠 Keep your state logic pure while handling Telegram I/O declaratively
A regular effect runs as part of the transition that created it — fine for sending a message, wrong
for a network call: you don't want to block the chat's next input on it. An async effect runs
independently and reports back later as an Event, which re-enters the FSM through its own
transition(state, event) overload — no manual CoroutineScope, no posting results back by hand.
// The effect just carries what the handler needs
data class FetchCatFactEffect(val chatId: Long) : Effect
// ...and what comes back, once it's done
data class CatFactLoaded(override val chatId: Long, val fact: String) : Event
data class CatFactLoadFailed(override val chatId: Long, val errorMessage: String) : Event
// AsyncEffectHandler, not EffectHandler — returns an Event instead of an EffectResult
class FetchCatFactEffectHandler(
private val networkUseCase: FetchCatFactUseCase,
) : AsyncEffectHandler<FetchCatFactEffect> {
override suspend fun handle(context: ExecutionContext, effect: FetchCatFactEffect): Event =
networkUseCase()
.fold(
{ fact -> CatFactLoaded(effect.chatId, fact.text) },
{ error -> CatFactLoadFailed(effect.chatId, error.message ?: "Unknown error") },
)
}Register it with registerAsync instead of register, then add the effect from a transition like
any other:
val effectRegistry = defaultEffectRegistry().apply {
registerAsync(FetchCatFactEffect::class, FetchCatFactEffectHandler(useCase))
}
// inside a transition
transition {
newState = MyState.Loading
sendMessage(input.chatId, "Loading...")
add(FetchCatFactEffect(chatId = input.chatId)) // fire-and-forget from here on
}And handle the result with the Event overload of transition — there's no entry equivalent for
events, since an event never starts a flow, only continues one:
override fun transition(state: MyState, event: Event): TransitionResult<MyState> =
when {
state is MyState.Loading && event is CatFactLoaded ->
transition { newState = MyState.Done(event.fact) }
state is MyState.Loading && event is CatFactLoadFailed ->
transition { newState = MyState.Error(event.errorMessage) }
else -> noTransition(state)
}Notes:
- The async effect's coroutine is tied to that chat's lifecycle — if the chat goes idle, it's cancelled along with everything else for that chat.
- A dispatcher whose async handler doesn't need
Botaccess can implementAsyncEffectHandlerdirectly, as above. One that does needsBotshould implementTelegramAsyncEffectHandlerinstead — same relationship asEffectHandler/TelegramEffectHandler. - See
:example'sExampleDispatcherfor the full pattern in context (fetching a cat fact while showing a "Loading..." message).
Debounce (opt-in). By default two rapid inputs that each start the same async effect run
concurrently — whichever resolves last wins. If you want latest-wins semantics instead (typical for
"search as you type"), implement the Debounced marker on the effect:
data class SearchProductsEffect(val chatId: Long, val query: String) : Effect, Debounced {
override val debounceKey: Any get() = "search" // per-chat: same key cancels the previous in-flight search
}When a Debounced async effect is dispatched, the previous still-running handler for the same
debounceKey in the same chat is cancelled before the new one starts. Different keys don't
interfere, and effects without the marker are never auto-cancelled.
Add optional modules if you need persistence or compact callback routing:
dependencies {
// ... core + telegram as shown above
implementation("io.github.youndie.telek:persistence:<VERSION>")
implementation("io.github.youndie.telek:router:<VERSION>")
// only if you're building inline keyboards with typed routes (RowBuilder.callback(name, route)) —
// pick the one matching your transport
implementation("io.github.youndie.telek:router-telegram:<VERSION>")
// implementation("io.github.youndie.telek:router-ktg:<VERSION>")
}:router itself doesn't depend on any transport — the route encode/decode logic (Route,
RouteRegistry, @RouteContext) is transport-agnostic. :router-telegram and :router-ktg add the
one bit of glue that needs a transport: the RowBuilder.callback(name, route) extension used below.
Persist user states between bot restarts using the persistence module. It provides a simple JSON file storage and a UserStateStore implementation.
Key components:
FileStateStorage<T : State>— saves/loads states as JSON files, one perConversationKeystateStorageOf<T>()— convenience factory forFileStateStoragePersistableUserStateStoreImpl<T : State>— drop‑in replacement for the default in‑memory store
File access goes through okio rather than java.io, so paths are
okio.Path and the module works on native targets too. Both take an optional fileSystem — pass
okio's FakeFileSystem to test a flow's persistence without touching the disk.
Usage:
// Suppose your flow uses states of type YourState : State
val userStateStore = PersistableUserStateStoreImpl<YourState>(
stateStorageOf(dir = "./state".toPath()) // ./state/<chatId>.<userId>.json, or <chatId>.json
)
val telek = Telek(
userStateStore = userStateStore,
dispatchers = listOf(ExampleDispatcher()),
effectExecutor = telegramEffectExecutor(contextSource),
)Notes:
- JSON serialization is powered by
kotlinx.serializationwithclassDiscriminator = "state_type"andignoreUnknownKeys = true. - When a transition returns a
FinalState, the storage entry is automatically deleted byPersistableUserStateStoreImpl— which is right when the state is all you keep per user. If something outlives the flow, see State that outlives a flow.
A message body is a document, not a string Telegram parses. Build it with the same message { }
block both transports take:
message {
bold("Order confirmed")
br2()
// Whatever the person typed. No escaping, at any call site, ever.
text("Thanks, ")
text(customerName)
text("!")
br2()
blockquote { text("Delivery on Friday") }
br()
spoiler("There is a free sticker in the box")
br2()
link(url = "https://example.test/orders", value = "Track it")
br()
code("ORD-4711")
}text, bold, italic, underline, strikethrough, spoiler, code, codeBlock, link,
blockquote, expandableBlockquote, plus br, br2, row and list for layout. They nest:
bold inside a blockquote is two ranges over the same text, which is what Telegram's entities are.
Why this instead of a string with parse_mode. Markup in a string is characters inside the
text, so any text the bot did not write — a name, a product title, an error from somewhere else —
can open markup that never closes, and Telegram rejects the whole message. From the outside that
is a button that does nothing. The defence is escaping, at every call site, and it is silently wrong
the day the parse mode changes. Here nothing is parsed, so there is nothing to escape.
Two projections, for tests:
body.plain // the text with no markup — assert on what was said
body.entities() // the ranges — assert on how it was styledMessageText.plain("…") builds a body that is exactly one literal string, which is what you want
for anything a person typed.
A conversation has exactly one telek State, and it is the flow's. A language, a timezone, a chosen
workspace, the id of the message your live menu occupies — none of those end when a wizard reaches
FinalState, and telek has no second concept for them on purpose: UserStateStore is the seam.
Implement it, keep the flow state as one field of your own row, and reset that field where the
shipped stores delete the entry.
class ProfileStore(
private val rows: MutableMap<Long, Profile> = mutableMapOf(),
) : UserStateStore {
override suspend fun get(key: ConversationKey): State? = rows[key.chatId]?.flow
override suspend fun update(
key: ConversationKey,
block: suspend (State?) -> UpdateResult,
): UpdateResult {
val row = rows[key.chatId] ?: Profile()
val result = block(row.flow)
// A FinalState ends the flow, not the person: clear the field, keep the row.
rows[key.chatId] = row.copy(flow = result.newState.takeUnless { it is FinalState })
return result
}
override suspend fun clear(key: ConversationKey) {
rows[key.chatId]?.let { rows[key.chatId] = it.copy(flow = null) }
}
}The reason to do it here rather than in a store of your own beside telek's is that both halves are
written inside the same update call, which Telek has already serialized per conversation. Two
stores have two writers and nothing ordering them.
Create compact, type‑safe callback data for inline keyboards and decode them easily.
Define routes:
@RouteContext(scope = "example", action = "select")
@Serializable
class ExampleRouteSelect(val number: Int) : Route
@RouteContext(scope = "example", action = "confirm")
@Serializable
class ExampleRouteConfirm : Route
@RouteContext(scope = "example", action = "cancel")
@Serializable
class ExampleRouteCancel : RouteBuild a registry and use helpers:
val registry = routes {
register<ExampleRouteSelect>()
register<ExampleRouteConfirm>()
register<ExampleRouteCancel>()
}
// Build inline keyboard with typed routes
sendMessage(
chatId = input.chatId,
message = { row { text("Choose:") } },
keyboard = {
row {
// `callback(name, route)` comes from the router-telegram (or router-ktg) module
callback(name = "Confirm", route = ExampleRouteConfirm())
callback(name = "Cancel", route = ExampleRouteCancel())
}
},
)
// Handle callbacks in a dispatcher
when (input) {
is Callback -> {
when {
input.isRouteOf<ExampleRouteConfirm>(registry) -> { /* handle confirm */ }
input.isRouteOf<ExampleRouteCancel>(registry) -> { /* handle cancel */ }
else -> input.tryDecode<ExampleRouteSelect>(registry)?.let { route ->
val n = route.number
// handle selection of `n`
}
}
}
else -> { /* other inputs */ }
}How it works:
- Each
Routemust be annotated with@RouteContext(scope, action)and with@Serializable— including routes with no fields at all. @RouteContextis a@SerialInfoannotation, so the serialization compiler plugin bakes it into the route's generatedSerialDescriptorand telek reads it from there. That's what lets:routerwork on every target:KClass.annotationsneeds JVM-only reflection, and:routerno longer depends onkotlin-reflectat all.- The encoder produces strings like
scope:action:key1_val1_key2_val2usingkotlinx.serializationproperties format. routes { register<T>() }adds decoders per route type, enablingisRouteOf<T>()andtryDecode<T>()onCallback.
Written down because an unwritten non-goal gets re-proposed roughly once per contributor, including by the author six months later, and each time it is argued from scratch. Each of these is a decision, so each comes with its reason rather than its verdict.
Its own Telegram client. telek is a state machine with transports attached, not an API binding.
Everything it knows how to send is an Effect handled by :ktg or :telegram; when Telegram adds
a method, the way to reach it is a handler in your own bot, not a pull request here.
JS and Apple targets. The target set is capped by ktgbotapi, which publishes jvm, js, linuxX64,
linuxArm64 and mingwX64 and no Apple targets — so Apple is not telek's to add. JS is left out for a
reason of its own: it would force an expect/actual for Dispatchers.IO, which lives in
coroutines' concurrent source set rather than in common, and nothing has asked for it.
A third transport. Two already cost one adaptation, one test and one documentation section per
input type. :ktg is the transport and :telegram is in maintenance — still built, still tested,
still published, not deprecated, and not receiving new input types unless somebody asks.
Generalising the product beyond Telegram. The core genuinely is transport-agnostic: nothing in
:core imports a Telegram type, and Input is an ordinary interface a bot can implement itself.
That is a property of the design, and shipping it as a product — adapters for other chat systems, a
general "interactive systems" runtime — is a different undertaking that nobody has asked for.
Depend on the property if it suits you; do not expect the product.
Implicitly cancelling an async effect when new input arrives. Guarding on state inside the
transition is the right answer, because only the dispatcher knows whether a late result is worthless
or still worth applying — an engine that discarded it would be guessing, invisibly. Where
cancellation is right, Debounced is the opt-in.