Migration: v4

v4 changes four contracts and a handful of names. There is no deprecation period — the old forms are gone, so each is one mechanical pass — and the builder pipeline, handlers, sessions, flows and dialogs are untouched.

  1. A storage backend implements two more things. KeyValueStorage gained compare_and_set and a paged scan. Only code that implements the record is affected; code that uses a backend is not.
  2. A reply lands where the update came from. Every reply.* call passes the forum topic, business connection and direct-messages topic of the update on, so a bot in a forum stops posting to General.
  3. A router composes; RouterTree is gone. Everything the *_on_tree functions did, the plain ones do.
  4. A pre-handler annotates the update’s scope instead of returning a dictionary.

Plus request parameter records that now match Bot API 10.3 — new fields, a few spec-driven renames — and the removals listed at the end.

Storage backends

If you wrote your own KeyValueStorage, add the two fields:

storage.KeyValueStorage(
  get:, set:, set_with_ttl:, delete:,
  // Write only if the key holds `expected` (`None`: absent or expired), as
  // one atomic step; answer whether it was written.
  compare_and_set: fn(key, expected, value, ttl_ms) {
    db_update_if(db, key, expected, value, ttl_ms)
  },
  // One page of live keys in key order, and the cursor of the next page.
  scan: fn(prefix, cursor, limit) {
    use page <- result.map(db_keys(db, prefix, after: cursor, limit:))
    #(page, storage.next_cursor(page, limit))
  },
)

compare_and_set has to be atomic on the backend — a guarded UPDATE … WHERE value = $2 … RETURNING key, a Lua script — because store.update and the job scheduler rely on it across processes and nodes. Run telega/testing/storage.check from your test suite; it is the contract as a test, and the one the shipped backends pass.

Callers of scan change too:

v3v4
kv.scan(prefix)storage.scan_all(kv, prefix) — or page yourself with kv.scan(prefix, cursor, limit)
a hand-written record that only re-types errorsstorage.map_error(kv, fn(err) { … })

telega_storage_sqlite, telega_storage_postgres and telega_storage_redis are released at 4.0.0 with both; telega/storage/ets has them.

Replies land in the topic they were asked in

reply.with_text & co., chat_action, payments.send and a dialog window now send with the update’s message_thread_id (a forum topic), business_connection_id and direct_messages_topic_id, read through the new update.message_thread_id / business_connection_id / direct_messages_topic_id. Nothing to change for a bot in private chats and plain groups. In a forum, replies move from General into the topic the message came from — which is what users expect, and a change in behaviour all the same. A bot that wants General on purpose sends through api.* with message_thread_id: None. A handler of on_business_message can drop its hand-built api.send_message and reply.text like everyone else.

reply.with_photo_bytes still sends to the chat only.

store.update is a compare-and-set

It reads, applies your function and writes with compare_and_set; if another instance wrote in between, it reads again and reapplies. Two consequences:

Router composition

RouterTree is gone. router.append and router.branch add branches to any Router; compose / compose_many return a Router; everything a tree had of its own is the plain function now.

v3v4
router.tree(name)router.new(name)
router.tree_fallback(tree, h)router.fallback(r, h)
router.use_middleware_on_tree(tree, mw)router.use_middleware(r, mw)
router.with_catch_handler_on_tree(tree, h)router.with_catch_handler(r, h)
router.tree_name / handle_tree / tree_routablerouter.name / handle / routable
router.tree_registered_commands / tree_allowed_updatesrouter.registered_commands / allowed_updates
telega.router_tree(tree)telega.router(r)

One behavioural difference: middleware and the catch handler of a composed router now wrap whatever handled the update, including branches added after them and the fallback — which the *_on_tree forms never reached.

Pre-handlers annotate the scope

A pre-handler returns bot.Continue(annotate: fn(Scope) -> Nil) instead of Continue(annotations: Dict), writes typed values into the update’s scope, and handlers read them back with scope.get:

// v3
bot.Continue(annotations: dict.from_list([#("locale", "de")]))
// ... bot.annotation(ctx, "locale")

// v4
const locale_key: scope.Key(String) = scope.Key("locale")

bot.Continue(annotate: fn(scope) { scope.put(scope, locale_key, "de") })
// ... scope.get(ctx.scope, locale_key)

bot.annotation and PreContext.annotations are gone.

Context

Context lives in telega/context; bot.Context is an alias, so imports keep working. The record lost chat_subject, start_time and annotations. Build one by hand (tests, custom runners) with context.new, or the helpers in telega/testing/context.

Request parameter records

Every *Parameters record matches Bot API 10.3, and codegen now fails on drift. Two kinds of change:

v3v4
SendLivePhotoParameters.medialive_photo
CreateChatSubscriptionInviteLinkParameters.period / amountsubscription_period / subscription_price
GetUserPersonalChatMessagesParameters.message_idslimit
GetManagedBotAccessSettingsParameters.bot_iduser_id
SetManagedBotAccessSettingsParametersuser_id, is_access_restricted, added_user_ids
AnswerGuestQueryParametersguest_query_id, result: InlineQueryResult
GetBusinessAccountGiftsParameters.exclude_limitedexclude_limited_upgradable / exclude_limited_non_upgradable (+ exclude_from_blockchain)

The reply.* shortcuts fill all of this in, so most bots touch none of it.

Smaller changes you may hit

Removed

GoneInstead
telega/menu_builder (deprecated in 3.0.0)a dialog window with widget.select, widget.paged_select or widget.list_group
flow/handler.create_resume_handler, create_resume_handler_with_keyboardthe flow registry resumes callbacks itself
bot.cancel_conversation(bot:, key:)telega.cancel_conversation / bot.cancel_conversation_in
encoder.bot_command_scope_to_jsonencoder.encode_bot_command_scope
bot.annotation, PreContext.annotationsscope.get (above)

Worth adopting while you are here

Checklist

  1. Custom storage backend: add compare_and_set and the paged scan, run testing/storage.check. Callers: kv.scan(p) → storage.scan_all(kv, p); error-mapping wrappers → storage.map_error.
  2. router.tree* / *_on_tree / telega.router_tree → the plain names.
  3. Pre-handlers: Continue(annotations:) → Continue(annotate:); bot.annotation → scope.get.
  4. Hand-built *Parameters records: add the new fields, apply the renames.
  5. RequestQueueConfig: drop retry_delay and max_retries.
  6. Pass a pure function to store.update.
  7. Bump every telega_* package to 4.0.0 together with the core.
✨ Search Document