Dialogs
Dialogs are declarative, single-message UIs for multi-step interactions (an
aiogram-dialog analog). A dialog
is a set of windows; a window is a pure render function plus event
handlers. The engine keeps one live message and edits it on every
transition, builds and parses button callback data itself, provides Back
navigation, managed keyboard widgets, sub-dialogs, and persistence through any
flow storage backend.
┌─────────────────────────────┐
│ Settings │ one Telegram message,
│ lang: en, name: Alice │ edited in place on every
├─────────────────────────────┤ button press / text input
│ [ EN ] [ RU ] │
│ [ Name ] │
│ [ Done ] │
└─────────────────────────────┘
When to Use Dialogs
Use a dialog when the interaction is one message with buttons that morphs in place: settings panels, booking wizards, browsable catalogs, confirmation screens.
Dialogs vs Conversations vs Flows vs Menu Builder
The same table lives in the README
alongside the rule for which layer gets an update — worth reading once,
because a conversation wait_* and a parked flow step cannot both own the
next one.
Dialogs compile to flows, so they inherit
persistence, TTL, and /cancel — the difference is the level of abstraction:
| Conversations | Flows | Menu Builder | Dialogs | |
|---|---|---|---|---|
| UI model | bot sends messages | you send/edit manually | one menu message | one live message, auto edit-or-send |
| Persistence | in-memory | storage backend | in-memory | storage backend (via flow) |
| Callback data | manual | manual | generated | generated + validated (64 bytes) |
| Back navigation | no | manual (Back action) | built-in | built-in |
| Media | manual | manual | no | text ↔ media transitions handled |
| Reusable selects/pagination | no | no | pagination | widgets (pager, radio, multiselect, …) |
| Composition | nested calls | subflows | nested menus | sub-dialogs |
| Best for | quick Q&A forms | branchy processes with custom messages | standalone menus outside a process | screen-like UIs: settings, wizards, catalogs |
menu_builder is deprecated: a dialog window with widget.select,
widget.paged_select or widget.list_group renders the same menu and keeps
its state, its history and its callback-data budget. It still compiles and
will not be removed without a major release, but it will not grow. If you are
hand-writing editMessageText calls and parsing callback payloads inside a
flow — that is the same sign, from the other direction.
Terminology
| Term | Description |
|---|---|
| Dialog | A set of windows compiled into one flow; one live instance per (dialog, chat, user) |
| Window | A screen: pure render(state, ctx) -> RenderedWindow + event handlers |
| State | Your own type, serialized with a codec you provide; persisted with the instance |
| Action | What a handler returns: Stay, Goto, Back, Done, StartSub |
| Widget | A managed keyboard fragment (pager, radio, …) with its own persisted store |
| Sub-dialog | A reusable dialog started from a window, sharing the live message |
Quick Start
import gleam/option.{None, Some}
import telega/dialog
import telega/dialog/types.{ActionButton, RenderedWindow}
import telega/flow/registry as flow_registry
import telega/flow/storage as flow_storage
import telega/format
pub type Settings {
Settings(lang: String, name: String)
}
fn render_menu(settings: Settings, _ctx) -> RenderedWindow {
RenderedWindow(
text: format.build()
|> format.bold_text("Settings")
|> format.line_break()
|> format.text("lang: " <> settings.lang <> ", name: " <> settings.name)
|> format.to_formatted(),
buttons: [
[ActionButton("Name", "name")],
[ActionButton("Done", "done")],
],
media: None,
)
}
fn handle_menu(settings, event: types.ActionEvent, _ctx) {
case event.action_id {
"name" -> Ok(types.Goto("name", settings))
"done" -> Ok(types.Done(settings))
_ -> Ok(types.Stay(settings))
}
}
fn render_name(_settings, _ctx) -> RenderedWindow {
RenderedWindow(
text: format.build()
|> format.text("What's your name?")
|> format.to_formatted(),
buttons: [[ActionButton("‹ Back", "back")]],
media: None,
)
}
pub fn create_settings_dialog(storage) {
let assert Ok(settings) =
dialog.new(
id: "settings",
storage:,
initial_state: fn() { Settings(lang: "en", name: "") },
encode_state: encode_settings,
decode_state: decode_settings,
)
|> dialog.window(id: "menu", render: render_menu, on_action: handle_menu)
|> dialog.window_with_input(
id: "name",
render: render_name,
on_action: fn(settings, event, _ctx) {
case event.action_id {
"back" -> Ok(types.Back(settings))
_ -> Ok(types.Stay(settings))
}
},
on_text: fn(settings, text, _ctx) {
Ok(types.Goto("menu", Settings(..settings, name: text)))
},
)
|> dialog.initial("menu")
|> dialog.on_done(fn(settings, ctx) { save_settings(settings, ctx) })
|> dialog.build()
settings
}
Wire it up through a flow registry:
let registry =
flow_registry.new_registry()
|> dialog.attach_on_command("settings", settings_dialog)
|> flow_registry.register_cancel_command("cancel")
let router = flow_registry.apply_to_router(router, registry)
That is the whole loop: /settings sends the menu; every press edits the
same message; Done runs on_done and removes the keyboard. build()
returns Result — window ids are validated up front (duplicates, unknown
initial, reserved characters, callback-data budget).
State and Codecs
The dialog state is your own type. It is persisted (as a string) in the flow
instance, so provide a codec at dialog.new:
dialog.string_codec()— for a plainStringstate.dialog.json_codec(encoder, decoder)— for records, viagleam/json+gleam/dynamic/decode(same pattern as session serialization).
Handlers are pure functions from the old state to an action carrying the new state — there is no hidden mutation:
"lang" -> Ok(types.Stay(Settings(..settings, lang: "ru")))
If a persisted state fails to decode after a deploy that changed its shape,
the dialog logs a warning and falls back to initial_state() instead of
crashing.
Windows and Actions
render must be pure — its only “effect” is reading ctx (e.g. for i18n).
This is what makes windows snapshot-testable without a network (§ Testing).
A handler returns a DialogAction:
| Action | Effect |
|---|---|
Stay(state) | save state, re-render the current window (edit in place) |
Goto(window_id, state) | save state, go to the window; pushed to history |
Back(state) | save state, one step back in the history |
Done(state) | save state, run on_done, remove the keyboard, delete the instance |
StartSub(sub_id, args, state) | start a sub-dialog (§ Sub-dialogs) |
Shown(mode, action) | carry out action, showing whatever window it lands on with mode (§ Errors and Robustness) |
Buttons in RenderedWindow.buttons:
ActionButton(text, action_id)→ callback datadlg:<dialog>:<window>:<action>ActionArgButton(text, action_id, arg)→…:<action>:<arg>(e.g. a list item id)UrlButton,WebAppButton— pass-throughNoopButton(text)— non-clickable (header/counter), rendered as a copy-text button
on_action receives an already-parsed ActionEvent(action_id, arg), never a
raw payload string.
Data a window does not hold
A window sometimes renders things that do not belong in its state: the
user’s open orders, today’s price, a row that another handler may have
changed since. window_with_data splits that in two — load reads the
world, render stays a pure function of (state, data):
|> dialog.window_with_data(
id: "orders",
load: fn(_state, ctx) { db.open_orders(ctx.dependencies.db, ctx.update.from_id) },
render: fn(_state, orders, _ctx) { order_list(orders) },
on_action:,
)
load runs on every render of the window — the opening one and each
re-render after a press — which is what keeps the screen from going stale;
keep it to one cheap read and put anything expensive in the state or in
dependencies. render is still snapshot-testable on its own: hand it data
you made up.
For a window that also takes text or carries widgets, pass the same pairing
as its render: dialog.with_data(load:, render:).
Text input
A window added with window_with_input also accepts text (on_text).
Windows without on_text swallow text politely: the message is consumed and
the window re-rendered, so the user always sees the current screen. The
dialog is modal to text but not to commands — commands still reach the
router, so always register a cancel command
(flow_registry.register_cancel_command).
Validation errors belong in the state, not in ad-hoc extra messages:
fn handle_date_text(state, text, _ctx) {
case validate_date(text) {
Ok(Nil) -> Ok(types.Goto("time", State(..state, date: text, error: None)))
Error(key) -> Ok(types.Stay(State(..state, error: Some(key))))
}
}
Media Windows
Set RenderedWindow.media to attach a photo/video/animation/document; the
window text becomes the caption. media is a file_id or URL (no
attach:// uploads yet).
The Bot API cannot edit a text message into a media one (or back), so the engine tracks the live message kind and picks a strategy:
| was \ becomes | text | media |
|---|---|---|
| — (no message) | sendMessage | sendPhoto/sendVideo/… |
| text | editMessageText | delete (best effort) + send |
| media (any kind) | delete (best effort) + send | editMessageMedia |
deleteMessage is best effort (messages older than 48 hours cannot be
deleted): the failure is swallowed and the fresh message is sent anyway.
Media → media of a different kind is a single editMessageMedia — it swaps
the file, the media type, and the caption at once.
Widgets
Widgets are managed keyboard fragments from telega/dialog/widget: the
engine renders their button rows after the window’s own buttons, handles
their callbacks itself (bypassing on_action), and persists their state with
the dialog instance — selections and page positions survive restarts.
import telega/dialog/widget.{SelectItem}
|> dialog.window_with_widgets(
id: "prefs",
render: render_prefs, // just the prompt + your own buttons
on_action: handle_prefs,
widgets: [
widget.radio(id: "zone", items: zone_items, default: Some("hall")),
widget.multiselect(id: "extras", items: extra_items,
min: 0, max: 3, done: "confirm"),
],
)
Built-ins:
| Widget | Behavior | Read the value with |
|---|---|---|
pager(id:, page_size:, total:) | ‹ 2/5 › row; hidden when one page | widget.current_page |
select(id:, items:, columns:, on_selected:) | one-shot choice grid; a press calls on_selected | — (nothing stored) |
radio(id:, items:, default:) | single choice, ●/○ marks | widget.radio_value |
multiselect(id:, items:, min:, max:, done:) | checkboxes; the done button shows only within min/max and emits Goto(done, state) | widget.multiselect_values |
paged_select(id:, items:, page_size:, columns:, on_selected:) | select + pager in one | widget.current_page |
counter(id:, min:, max:, step:, initial:) | − 3 + row, clamped to the range | widget.counter_value(default:) |
calendar(id:, from:, to:, on_picked:) | month grid with ‹/› paging; days outside the range are blanks | — (on_picked gets the calendar.Date) |
list_group(id:, items:, actions:, on_action:) | a row of buttons per item — “Edit / Delete” on every row | — (on_action gets the action and item ids) |
SelectItem(id:, label:) ids travel in callback data — keep them short (the
64-byte limit is validated on every render).
Widget state lives in a per-widget store, persisted under the dialog instance. Read it from any render or handler:
let zone =
dialog.widget_store(ctx, window_id: "prefs", widget_id: "zone")
|> widget.radio_value
|> option.unwrap("hall")
Note that radio’s default is only a visual pre-selection — radio_value
stays None until the user actually picks, so apply the same default when
reading.
Custom widgets construct types.KeyboardWidget directly: render returns
button rows, on_event handles w:<widget_id>:<cmd> presses and returns
StoreUpdated (re-render in place) or Emit(action) (delegate outward).
Declare goto_targets and static_actions so build() can validate window
references and the callback-data budget.
Sub-dialogs
A sub-dialog is a reusable dialog attached to a parent — its state type may differ. It takes over the same live message; when it finishes, the parent window that started it receives the result.
// A reusable address dialog with its own state.
pub type AddressState {
AddressState(city: String, street: String)
}
pub fn create_address_dialog(storage) -> dialog.Dialog(AddressState, s, e, d) {
// ...windows "city" and "street"; the last one returns Done(state)
}
// Attach to the parent and handle the result:
let address_dialog = create_address_dialog(storage)
|> dialog.subdialog(sub: address_dialog, init: fn(_parent_state, _args) {
AddressState(city: "", street: "")
})
|> dialog.on_sub_result(window: "confirm", sub: address_dialog, handler:
fn(state, address: AddressState, _ctx) {
Ok(types.Stay(BookingState(..state, address: Some(address.city <> ", " <> address.street))))
})
The handler is handed the sub’s final state in the sub’s own type —
passing sub to on_sub_result is what lets the decoding happen there, so
neither side writes a codec or agrees on dict keys by hand.
Start it from any window handler:
"address" -> Ok(types.StartSub("delivery_address", dict.new(), state))
Semantics:
-
init(parent_state, args)builds the sub’s starting state (argscome fromStartSub— use them to pass parameters). -
The sub edits the same live message; message id/kind, waits, TTL and persistence are shared with the parent. A half-entered sub survives restarts like everything else.
-
The sub’s
Done(sub_state)goes to the starting window’son_sub_resultfor that sub (default, and for any sub with no handler there: just re-render the window). Handlers are registered per#(window, sub), so one window can start several sub-dialogs and read each back in its own type;build()rejects a handler for an unknown window or an unattached sub. -
Backon the sub’s first window cancels the sub: the parent window is re-rendered,on_sub_resultis not called. -
A sub cannot
Gotoparent windows (its navigation is namespaced), and the sub’s ownstorage/ttl/labels/on_doneare ignored while attached. -
Nesting is transitive. A dialog that has sub-dialogs of its own can be attached:
build()flattens the whole tree into one window namespace (<sub>.<inner>.<window>) and keys each attachment by the same path, so aStartSub("inner")insidemiddleresolves tomiddle.inner. At runtime the entered dialogs form a stack:Donepops one level and hands its result to that level’s return window, and a boundaryBackcancels one level. Chaining still works too —on_sub_resultmay return anotherStartSub.Ids get longer with depth and they travel in callback data, so the 64-byte budget is what actually limits how deep you can go;
build()measures every namespaced window against it.
i18n
render receives the update’s Context, so
telega_i18n works out of the box — translate inside the
render exactly like in any handler:
fn render_confirm(state, ctx) {
RenderedWindow(
text: format.build()
|> format.text(i18n.t(ctx, "book.confirm", [#("date", state.date)]))
|> format.to_formatted(),
...
)
}
Engine-generated texts — the multiselect Done button, pager arrows,
check marks, and the “this menu is outdated” callback answer — come from
Labels. Localize them with with_labels; the factory receives the
Context, so the i18n middleware’s per-update locale applies:
fn dialog_labels(ctx) -> types.Labels {
let defaults = types.default_labels()
types.Labels(
..defaults,
done: i18n.t(ctx, "book.prefs_done", []), // "✅ Done" / "✅ Готово"
stale: i18n.t(ctx, "common.stale", []), // "⏳ This menu is outdated"
)
}
|> dialog.with_labels(dialog_labels)
The defaults are wordless unicode symbols (‹, ›, ●, ☑, ✓, ⏳), so
an unlocalized dialog never shows English words to non-English users.
Callback Data, Limits, Reserved Characters
Callback data follows the dlg:<dialog>:<window>:<action>[:<arg>] scheme
(widget events use the w:<widget_id>:<cmd> action namespace). Telegram
limits callback data to 64 bytes:
build()validates every static prefix and widget action, including the<sub_id>.<window_id>namespace of sub-dialog windows;- dynamic parts (
ActionArgButtonargs, select item ids) are validated on every render — a violation is logged as a render error and the dialog keeps waiting (a misconfiguration must be visible, not silently degraded).
Keep dialog/window/action ids short; use short keys or indices for list
items, not raw titles. Two characters are reserved: : (the scheme
separator, forbidden in all ids) and . (the sub-dialog namespace,
forbidden in dialog and window ids).
Stale buttons
Presses on outdated messages are guarded twice: the window_id in the
payload catches presses on old windows, and the pressed message’s id is
compared against the tracked live message — a press on an old copy of the
live window (left behind by a recreate fallback or restart) is caught too.
Both answer the callback with labels.stale and do nothing.
Presses on messages of an already finished dialog are handled by a
fallback that attach/attach_on_command register automatically: the
callback is answered with labels.stale instead of hanging. No hand-written
router fallback is needed.
Several flows waiting at once
attach also restricts the dialog’s auto-resume to its own dlg:<id>:
payloads, so a dialog and another waiting flow (or two dialogs) can coexist:
a press on the other flow’s keyboard is not swallowed by the dialog. Plain
hand-written flows can opt into the same behavior with
flow_registry.with_callback_filter.
Two more rules apply whenever several flows are waiting, with or without a filter:
- A step that asked for
action.wait_callbackis only resumed by a callback query. A text message goes to a flow that is waiting for text (action.wait), never to the one waiting for a button press. - When more than one flow accepts the update, the one whose instance was updated most recently wins — the flow the user is actually in the middle of. Ties break on the flow name, so the choice never depends on hash order.
Errors and Robustness
- User handler errors (an
Error(e)fromon_action/on_text/…) are logged, emit["telega", "dialog", "error"]telemetry, and re-render the current window — the dialog never dies silently. - Render/API errors (network, over-budget buttons) are logged loudly and the instance keeps waiting, so the user can retry.
- Edit fallbacks: “message is not modified” is treated as success;
“message to edit not found” / “message can’t be edited” fall back to a
fresh send. The classifiers live in
telega/error(error.is_message_not_modified& co, aliased intelega/dialog/render) and are useful outside dialogs. - Webhook reply: under
handle_bot_with_replythe dialog’s own sends opt out of webhook-reply claiming (webhook_reply.without_claim) — the engine needs the real message id to edit the live message later, and a claimedsendMessagewould yield a fake stub id. dialog.with_show_mode(builder, mode)decides when the dialog replaces its live message instead of editing it.EditLive(the default) always edits, which is right for button presses. A dialog with text-input windows wantsResendOnUserMessage: after the user types, an edited window is scrolled above their own message and easy to miss, so the window is deleted and resent below it.AlwaysResendnever edits. The mode can be narrowed twice over, most specific winning:dialog.with_window_show_mode(builder, window:, mode:)sets it for one window (the one window that asks the user to type, in a dialog that otherwise edits in place —build()rejects an unknown window id), andtypes.Shown(mode, action)sets it for one action, covering every render that action leads to and nothing after it.dialog.on_message(builder, window:, handler:)accepts a photo, video, voice note, audio or location sent whilewindowis open, classified as aMessageInput(the raw update stays onctx.update). Without it those messages are politely ignored and the window re-renders, exactly like text withouton_text.alert(ctx, text)/toast(ctx, text)show a modal alert or a toast from insideon_action,on_sub_resultor a widget handler; the engine then skips its automatic spinner-removing callback answer for that event. The automatic answer happens after the handler and the re-render, so a handler reached later in the same press — anon_sub_resultrunning when a sub-dialog isDone— still gets to be the answer instead of a second one Telegram would reject.
Several dialogs at once
Dialogs are independent: two of them open for the same user are two instances, two live messages and two callback prefixes, and each resumes on its own presses. Nothing is suspended when one starts another — which is what makes this different from a sub-dialog, where the sub takes over the parent’s message and returns into its window.
What they do share is a way back. dialog.start called from inside
another dialog’s handler records who opened the new one, and the new one can
hand the screen back when it finishes:
// menu dialog
"settings" -> {
let _ = dialog.start(ctx, registry, "settings")
Ok(types.Stay(state))
}
// settings dialog
|> dialog.on_done(fn(_state, ctx) {
use #(ctx, _returned) <- result.map(dialog.return_to_caller(ctx, registry))
ctx
})
return_to_caller re-renders the caller in place and answers False if
nothing opened this dialog or the caller has since finished — it never
starts anything. dialog.caller(ctx) is the same answer as a value, and is
readable from window handlers as well as on_done. The caller is recorded
when a dialog is started; a start that only resumes an already-open dialog
leaves the way back it already had.
Reach for a sub-dialog when the second screen is a step of the first (an address inside a booking); reach for two dialogs when they are two things the user can have open at once (a catalog and its settings).
Lifecycle
- One live instance per
(dialog, chat, user): a repeated start command resumes (re-renders) the current dialog.dialog.restart(ctx, registry, id)is the hard reset (delete + start). dialog.attach(registry, dialog)registers without a trigger — start programmatically withdialog.start(ctx, registry, dialog_id)(e.g. after a permissions check).dialog.attach_on_command("book", dialog)binds a command.dialog.with_ttl(ms:)expires an abandoned dialog (lazy check on the next event), inherited from the flow engine./cancelis the flow registry’s cancel command — register it; the dialog is modal to text, so it is the user’s escape hatch.
Testing
Dialogs are designed for snapshot testing with the pure canonicalizers from
telega/testing/render (see testing.md § Snapshot Testing).
Three levels:
Level 1 — pure window frames. render(state, ctx) has no effects, so
frame every meaningful state without a network:
render_confirm(filled_state(), ctx)
|> testing_render.window_frame
|> birdie.snap(title: "booking:confirm:frame_en")
For windows that read widget stores, seed them first with
widget.seed_store(ctx, window_id:, widget_id:, store:) — seeds live in the
context’s Scope (telega/scope), the same per-update scratch space the
engine stashes real stores in, so pass the ctx the render is about to
receive.
Per-locale frames: pin the same frame once per locale — with
telega_i18n set the locale around the render:
telega_i18n.enter(ctx, catalog:, locale: "ru")
render.window_frame(booking.render_confirm(state, ctx))
|> birdie.snap(title: "booking:confirm:frame_ru")
telega_i18n.leave(ctx)
Level 2 — engine transcripts. Drive the compiled flow with
telega/testing/dialog and a mock client, then snapshot the full visible
API-call sequence (sendMessage, editMessageText, answerCallbackQuery, …):
import telega/testing/dialog as testing_dialog
let flow = dialog_engine.compile(dialog.compiled(my_dialog))
let #(client, calls) = testing_dialog.text_client()
let driver =
testing_dialog.driver(flow:, client:, dialog_id: "settings")
|> testing_dialog.with_chat(chat_id: 42)
testing_dialog.start(driver, command: "/settings")
testing_dialog.press(driver, data: "dlg:settings:menu:lang")
testing_dialog.send_text(driver, text: "Alice")
mock.get_calls(calls)
|> testing_render.calls_transcript
|> birdie.snap(title: "dialog:engine:happy_path")
The driver stands in for the flow registry’s auto-resume, so it exercises the
same code path a real update takes. press_on_message simulates a press on an
outdated copy of the live message, send_photo a media message, and
instance(driver) reads the persisted state for direct assertions.
media_client() is text_client() plus a deleteMessage answer, for dialogs
with media windows or sub-dialogs.
Level 3 — error-path regressions. Script Telegram’s answers with
mock.stateful_client/mock.routed_client (400 “message is not modified”,
“message to edit not found”, …) and snapshot the recovery.
Level 0 — the whole map. Before any of that,
telega/testing/graph.of_dialog(dialog:, ctx:) walks the dialog and exports
every window and transition as Graphviz DOT or Mermaid — buttons, widget
navigation, sub-dialog enter/return included. It reads the same pure
render/on_action functions the tests do, so it needs no network, and its
output is deterministic enough to snapshot as a navigation regression test.
See testing.md § Graph Export.
test/telega/dialog_test.gleam, dialog_widget_test.gleam,
dialog_sub_test.gleam, graph_test.gleam in the repository and
examples/06-restaurant-booking/test/booking_dialog_test.gleam are working
references for all of the above.
Updating a dialog from outside
A job that finishes elsewhere — an export, a payment webhook, a cron tick —
can re-render what the user is currently looking at. Build a context for them
with telega.background_context, then dialog.refresh:
let assert Ok(ctx) = telega.background_context(bot, chat_id:, user_id:)
let assert Ok(#(_ctx, refreshed)) =
dialog.refresh(ctx, registry, dialog_id: "export")
refresh re-runs the current window’s render with the current state, so
anything it reads (the session, an injected service, your database) is
re-read. It never opens a dialog: a user with nothing open gets False
and no API call.
A background context carries the same config, dependencies, bot info and
persisted session a handler would see, but there is no chat instance behind
it, so:
ctx.updateis a placeholder holding only the ids — do not read its content;wait_*andcancel_conversation_indo nothing (start conversations from a real update);ResendOnUserMessageedits rather than resends, since there is no message from the user to land below.
Telemetry
Dialogs emit ["telega", "dialog", <event>] with dialog_id/window_id
metadata: render (with duration), action (with action_id),
render_error, error, sub_start, sub_done, sub_cancel — on top of
the flow-level ["telega", "flow", ...] events (flow_name is
"__dialog:" <> id). See telega/telemetry for the handler API.
Example
examples/06-restaurant-booking uses
every feature on this page: text-input windows with in-state validation, a
paged_select/select/radio/multiselect keyboard, a media window
(text ↔ photo transitions), a reusable address sub-dialog, localized labels
via telega_i18n, SQLite persistence, and per-locale snapshot tests.