Skip to content
youndiePublic

About

Type-safe Telegram bot toolkit for Kotlin: wizard flows and interactive systems as a state machine — state plus input gives a new state and effects

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

telek

ktlint kotlin telek core API Docs

JVM Linux x64 Linux arm64 license

type-safe kotlin toolkit for building Telegram bots, wizard-flows, and other interactive systems powered by FSM

🧩 state + input → newState + effects

📦 Installation

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.

💬 Usage with Telegram bot

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

🚀 Initialization

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 same on kotlin-telegram-bot

The heading above used to read "Using ktgbotapi instead", back when telegram was the transport and ktg was 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's Bot; on :ktg they are KtgEffectHandler / KtgAsyncEffectHandler taking ktgbotapi's TelegramBot, and the effect marker is KtgEffect rather than TelegramEffect.
  • kotlin-telegram-bot returns a result type where ktgbotapi throws, so the two transports' built-in handlers arrive at EffectFailed by different routes — the outcome a dispatcher sees is the same.
  • :telegram and :router-telegram are 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 same RowBuilder.callback(name, route) extension, over io.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().

📥 What an input can be

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) : Input

The 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.

🔑 What a conversation is keyed by

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, shared

connect() 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 chat

There 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.

⚡ Defining a Custom Effect

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

⏳ Async effects & Events

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 Bot access can implement AsyncEffectHandler directly, as above. One that does needs Bot should implement TelegramAsyncEffectHandler instead — same relationship as EffectHandler / TelegramEffectHandler.
  • See :example's ExampleDispatcher for 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.

📦 Optional modules

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.

💾 Persistence module

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 per ConversationKey
  • stateStorageOf<T>() — convenience factory for FileStateStorage
  • PersistableUserStateStoreImpl<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.serialization with classDiscriminator = "state_type" and ignoreUnknownKeys = true.
  • When a transition returns a FinalState, the storage entry is automatically deleted by PersistableUserStateStoreImpl — which is right when the state is all you keep per user. If something outlives the flow, see State that outlives a flow.

✍️ Formatting a message

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 styled

MessageText.plain("…") builds a body that is exactly one literal string, which is what you want for anything a person typed.

👤 State that outlives a flow

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.

🧭 Router module

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 : Route

Build 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 Route must be annotated with @RouteContext(scope, action) and with @Serializable — including routes with no fields at all.
  • @RouteContext is a @SerialInfo annotation, so the serialization compiler plugin bakes it into the route's generated SerialDescriptor and telek reads it from there. That's what lets :router work on every target: KClass.annotations needs JVM-only reflection, and :router no longer depends on kotlin-reflect at all.
  • The encoder produces strings like scope:action:key1_val1_key2_val2 using kotlinx.serialization properties format.
  • routes { register<T>() } adds decoders per route type, enabling isRouteOf<T>() and tryDecode<T>() on Callback.

🚫 What telek is not

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.

About

Type-safe Telegram bot toolkit for Kotlin: wizard flows and interactive systems as a state machine — state plus input gives a new state and effects

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages