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")
}
}
dependencies {
implementation("ru.workinprogress.telek:core:<VERSION>")
// pick one transport:
implementation("ru.workinprogress.telek:telegram:<VERSION>") // kotlin-telegram-bot
// implementation("ru.workinprogress.telek:ktg:<VERSION>") // ktgbotapi
}The core module contains the FSM engine, transitions, and effect system.
The telegram module provides integration
with kotlin-telegram-bot, and the
ktg module the same integration
on ktgbotapi β same API shape, pick whichever
Telegram client you already use (see Using ktgbotapi instead).
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. A plain
JVM Gradle project resolves the right variant automatically from Gradle module metadata; nothing
changes for JVM consumers.
telek integrates seamlessly with kotlin-telegram-bot
Each StateDispatcher describes one conversational flow β for example, a multistep wizard
Below is a simple dispatcher handling a confirmation dialog:
// 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(text = "Confirm", data = "example_confirm")
callback(text = "Cancel", data = "example_cancel")
}
},
)
}
}
// 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 callback data
if (input.data.contains("example_confirm")) {
sendMessage(input.chatId, "confirmed")
} else {
sendMessage(input.chatId, "canceled")
}
}
}
// For all other cases, no state transition
else -> noTransition(state)
}
}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 β no Telegram API calls inside your states
below is a minimal setup example using a parent coroutine scope, and interceptors.
val contextSource = TelegramContextSource()
val telek = Telek(
dispatchers = listOf(ExampleDispatcher()),
effectExecutor = telegramEffectExecutor(contextSource),
)
bot {
token = "telegram token"
dispatch { connect(telek, contextSource) }
}TelegramContextSource is what lets the effect executor reach the Bot instance: bot { } only
hands one out inside a dispatch { } handler, so the executor and connect() share one source that
resolves lazily on the first update. 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 ktg module is the same integration built
on ktgbotapi instead of kotlin-telegram-bot.
Everything above β dispatchers, transitions, sendMessage / editMessage / editMarkup, the text
and inline-keyboard DSLs β reads identically; only the imports change
(ru.workinprogress.telek.telegram.* β ru.workinprogress.telek.ktg.*) and the types they produce
are ktgbotapi's (InlineKeyboardMarkup, TelegramBot).
val bot = telegramBot("telegram token")
val contextSource = KtgContextSource(bot)
val telek = Telek(
dispatchers = listOf(ExampleDispatcher()),
effectExecutor = ktgEffectExecutor(contextSource),
)
bot.buildBehaviourWithLongPolling {
connect(telek, contextSource)
}.join()connect() is an extension on ktgbotapi's BehaviourContext: it subscribes onText and
onDataCallbackQuery, maps them to telek Message / Callback inputs keyed by chatId, and hands
the TelegramBot to the shared KtgContextSource. 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. Callback queries with no message attached (inline-mode ones) can't be
keyed by chatId and are ignored.
Unlike kotlin-telegram-bot, ktgbotapi hands out its TelegramBot up front, so KtgContextSource(bot)
usually resolves immediately; the deferred form (KtgContextSource() + provide(bot)) is still there
for wiring built before the bot exists.
Differences worth knowing:
- Effect handler interfaces are
KtgEffectHandler/KtgAsyncEffectHandler, taking ktgbotapi'sTelegramBotrather than kotlin-telegram-bot'sBot; the marker interface for effects isKtgEffect. - ktgbotapi reports API failures by throwing rather than returning a result type, so the built-in
handlers don't return a failure result of their own β the exception reaches
EffectExecutorImpl, which logs it and turns it into anEffectFailedthat reachesTelekInterceptor.onError. ContentMessage<TextContent>.asTelekInput(),DataCallbackQuery.asTelekInput()andMessage.telekChatIdare public, so a bot wiring its own updates (webhooks, a customFlowsUpdatesFilter) can reuse the mapping without going throughconnect().
The router equivalent is router-ktg β same RowBuilder.callback(name, route) extension, over
ru.workinprogress.telek.ktg.RowBuilder.
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("ru.workinprogress.telek:persistence:<VERSION>")
implementation("ru.workinprogress.telek:router:<VERSION>")
// only if you're building inline keyboards with typed routes (RowBuilder.callback(name, route)) β
// pick the one matching your transport
implementation("ru.workinprogress.telek:router-telegram:<VERSION>")
// implementation("ru.workinprogress.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 perchatIdstateStorageOf<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()) // files like ./state/<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.
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.