A Telegram bot is a state machine pretending not to be

Every Telegram bot starts as a when over the incoming message and ends as a pile of callbacks with a nullable awaitingCity field somewhere in a map. The reason is not laziness. It is that the framework hands you an event and no place to put the fact that this user is halfway through something.

telek starts from the shape that was there all along:

state + input → newState + effects

Pure transitions, effects on the side

A transition is a function. It takes the current state and the input, and returns the next state plus a list of things to do — send this message, edit that one, call an API. It does none of them.

That split is the whole design, and everything useful falls out of it:

  • A transition is testable with no bot, no network and no mocks. It is a function over data.
  • A conversation can be replayed from its inputs, because nothing was hidden in a closure.
  • Persisting a wizard is persisting a value, not a call stack — which is what makes it survive a restart.

A StateDispatcher describes one flow: a multi-step form, a checkout, an onboarding sequence. Flows compose rather than nest, so adding a step is adding a transition rather than another level of indentation.

Two transports, same shape

telek supports both kotlin-telegram-bot and ktgbotapi, as separate modules with the same API shape. You pick the client you already use:

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

This is only possible because the core never touches the network. If transitions performed their own sends, the transport would be baked into every one of them and there would be no seam to swap.

Native, because the core is pure

core, ktg, router, router-ktg, persistence and testing are published for JVM, linuxX64 and linuxArm64. A bot can ship as a single native Linux binary — no JRE on the box, a few tens of megabytes of RSS, and a start measured in milliseconds.

telegram and router-telegram stay JVM-only, because kotlin-telegram-bot is JVM-only. That is not a limitation to work around; it is the honest shape of the dependency, and a plain JVM project resolves the right variant from Gradle module metadata without noticing any of this.

The reason the split lands where it does is the same as before: the pure part has nothing platform-specific in it, so it goes everywhere. The part that speaks HTTP through a JVM library does not.


Source: github.com/youndie/telek · API docs