telega/dialog
Declarative dialogs
A dialog is a set of windows; a window is a pure render function plus
event handlers. The engine renders everything into one live message
(edit-or-send, including text ↔ media transitions — see
telega/dialog/render), parses button callback data itself, keeps a
navigation stack with Back, and persists all state in a FlowStorage —
the dialog survives restarts with any persistent backend
(Postgres/SQLite/Redis).
Dialogs compile to telega/flow state machines: a window is a step,
navigation is a flow action, delivery of callbacks/text into the active
window is the flow registry’s wait-token auto-resume. Nothing here needs
a dedicated router route.
Full guide (positioning vs conversations/flows/menu builder, widgets, sub-dialogs, i18n, testing): docs/dialogs.md.
Quick start
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
fn render_menu(state: MyState, _ctx) -> RenderedWindow {
RenderedWindow(
text: format.build() |> format.bold_text("Settings") |> format.to_formatted(),
buttons: [[ActionButton("Name", "name")], [ActionButton("Done", "done")]],
media: None,
)
}
fn handle_menu(state, event: types.ActionEvent, _ctx) {
case event.action_id {
"name" -> Ok(types.Goto("name", state))
"done" -> Ok(types.Done(state))
_ -> Ok(types.Stay(state))
}
}
let assert Ok(settings) =
dialog.new(
id: "settings",
storage: flow_storage,
initial_state: fn(_ctx) { MyState(name: "") },
encode_state: encode_my_state,
decode_state: decode_my_state,
)
|> dialog.window(id: "menu", render: render_menu, on_action: handle_menu)
|> dialog.window_with_input(id: "name", render:, on_action:, on_text:)
|> dialog.initial("menu")
|> dialog.on_done(save_settings)
|> dialog.build()
let registry =
flow_registry.new_registry()
|> dialog.attach_on_command("settings", settings)
|> flow_registry.register_cancel_command("cancel")
let router = flow_registry.apply_to_router(router, registry)
Behavior notes
- One live instance per
(dialog, chat, user): a repeated start command resumes (re-renders) the current dialog instead of opening a second one. Userestartfor a hard reset. - The dialog is modal: while it waits for a callback, plain text
messages are swallowed (the window is re-rendered). Commands still
reach the router — register a
/cancelviaflow_registry.register_cancel_command. - Widgets:
window_with_widgetsattaches managed keyboards fromtelega/dialog/widget(pager, select, radio, multiselect, paged_select) — the engine renders their rows, handles their callbacks and persists their state; read selections withwidget_store. - Sub-dialogs:
subdialogattaches another built dialog (its state type may differ); any window starts it withStartSub(sub_id, args, state)and receives its exported result inon_sub_result. The sub shares the live message and the parent’s storage/TTL/labels;Backon its first window cancels it; nesting is one level deep. - Stale buttons: a press on an outdated dialog message (an old
window, or an old copy of the live message) answers with
labels.staleand does nothing. Presses on messages of an already finished dialog are answered the same way by a fallback thatattachregisters automatically. - Errors: user handler errors are logged, emit
["telega", "dialog", "error"]telemetry and re-render the current window; failed renders (API errors, over-64-byte callback data) are logged loudly and keep the dialog alive.
Types
A validated dialog, ready to be attached to a flow registry. Internally
the windows are type-erased (they carry the state codec in closures); the
typed codec is kept alongside so the dialog can be attached as a
sub-dialog with subdialog.
pub opaque type Dialog(state, session, error, dependencies)
pub opaque type DialogBuilder(state, session, error, dependencies)
Values
pub fn alert(
ctx ctx: bot.Context(session, error, dependencies),
text text: String,
) -> Result(Nil, error.TelegaError)
Show a modal alert to the user who pressed the button. Call inside
on_action before returning an action; the engine will skip its
automatic spinner-removing answer for this event.
pub fn attach(
registry registry: registry.FlowRegistry(
session,
error,
dependencies,
),
dialog dialog: Dialog(state, session, error, dependencies),
) -> registry.FlowRegistry(session, error, dependencies)
Register the dialog in a flow registry without a trigger — start it
programmatically with start. Event delivery into the active window is
the registry’s standard auto-resume, so remember to finish with
flow_registry.apply_to_router.
Attaching also wires two routing guards for free: the dialog only
auto-resumes on its own dlg:<id>: callbacks (so several waiting
flows/dialogs can coexist), and presses on messages of an already
finished dialog are answered with labels.stale instead of hanging.
pub fn attach_on_command(
registry registry: registry.FlowRegistry(
session,
error,
dependencies,
),
command command: String,
dialog dialog: Dialog(state, session, error, dependencies),
) -> registry.FlowRegistry(session, error, dependencies)
Register the dialog and start it on a command (e.g. "settings" for
/settings). A repeated command while the dialog is active resumes it.
Wires the same routing guards as attach.
pub fn build(
builder builder: DialogBuilder(
state,
session,
error,
dependencies,
),
) -> Result(
Dialog(state, session, error, dependencies),
types.DialogBuildError,
)
Validate and build the dialog. Checks window ids for duplicates and the
reserved :/. characters, the initial window, on_sub_result and
widget window references, sub-dialog attachments, and that all
callback-data prefixes (including the <sub_id>. namespace of sub
windows) leave room within Telegram’s 64-byte limit.
Building also erases the state type: every window is wrapped so its closures decode/encode the state with this dialog’s codec, which is what lets sub-dialogs with different state types share one flow.
pub fn caller(
ctx ctx: bot.Context(session, error, dependencies),
) -> option.Option(String)
Which dialog opened the one being handled, if it was opened from another dialog rather than from a command or a plain handler.
Readable from a window’s handlers and from on_done — the answer belongs
to the update, so it survives the instance being deleted on the way out.
pub fn initial(
builder builder: DialogBuilder(
state,
session,
error,
dependencies,
),
window_id window_id: String,
) -> DialogBuilder(state, session, error, dependencies)
Set the window the dialog opens with.
pub fn json_codec(
encoder encoder: fn(state) -> json.Json,
decoder decoder: decode.Decoder(state),
) -> #(fn(state) -> String, fn(String) -> Result(state, Nil))
Codec pair from a JSON encoder + decoder:
let #(encode, decode) = dialog.json_codec(encode_settings, settings_decoder()).
pub fn new(
id id: String,
storage storage: types.FlowStorage(error),
initial_state initial_state: fn(
bot.Context(session, error, dependencies),
) -> state,
encode_state encode_state: fn(state) -> String,
decode_state decode_state: fn(String) -> Result(state, Nil),
) -> DialogBuilder(state, session, error, dependencies)
Start building a dialog. encode_state/decode_state serialize the
user state for persistence (precedent: session serialization); for simple
states see string_codec and json_codec.
initial_state receives the Context, so a dialog can open on state seeded
from the session, the injected dependencies or the sender — a form
pre-filled from the user’s profile, say.
pub fn on_done(
builder builder: DialogBuilder(
state,
session,
error,
dependencies,
),
handler handler: fn(
state,
bot.Context(session, error, dependencies),
) -> Result(bot.Context(session, error, dependencies), error),
) -> DialogBuilder(state, session, error, dependencies)
Called when a window returns Done: receives the final state. The live
message keeps its text but loses the keyboard.
pub fn on_message(
builder builder: DialogBuilder(
state,
session,
error,
dependencies,
),
window window: String,
handler handler: fn(
state,
types.MessageInput,
bot.Context(session, error, dependencies),
) -> Result(types.DialogAction(state), error),
) -> DialogBuilder(state, session, error, dependencies)
Accept non-text messages (a photo, a location, a voice note) on window.
Without it the engine politely ignores them and re-renders. The classified
input is in MessageInput; the raw update stays on ctx.update.
|> dialog.on_message(window: "avatar", handler: fn(state, input, _ctx) {
case input {
types.PhotoMessage(file_ids: [best, ..]) -> Ok(types.Goto("confirm", best))
_ -> Ok(types.Stay(state))
}
})
pub fn on_sub_result(
builder builder: DialogBuilder(
state,
session,
error,
dependencies,
),
window window: String,
sub sub: Dialog(sub_state, session, error, dependencies),
handler handler: fn(
state,
sub_state,
bot.Context(session, error, dependencies),
) -> Result(types.DialogAction(state), error),
) -> DialogBuilder(state, session, error, dependencies)
Handle what a sub-dialog started from window came back with.
The handler receives the window’s state and sub’s final state, in
sub’s own type — passing the sub itself is what lets the decoding
happen here rather than in a hand-written codec. It returns the next
action, Stay re-rendering the window with whatever it learned:
|> dialog.on_sub_result(window: "confirm", sub: address_dialog, handler:
fn(state, address: Address, _ctx) {
Ok(types.Stay(State(..state, address: Some(address.line))))
})
Registered per #(window, sub): one window can start several sub-dialogs
and react to each in its own type. A sub with no handler for the window it
returned to simply re-renders that window. build() rejects a handler for
an unknown window or for a sub that is not attached — either way it could
never run.
pub fn refresh(
ctx ctx: bot.Context(session, error, dependencies),
registry registry: registry.FlowRegistry(
session,
error,
dependencies,
),
dialog_id dialog_id: String,
) -> Result(
#(bot.Context(session, error, dependencies), Bool),
error,
)
Re-render a user’s open dialog without advancing it.
Unlike start, a user who has no live instance of this dialog is left
alone (False comes back) — a background refresh must not open a dialog
nobody asked for. Pair it with telega.background_context to update what
someone is looking at from a job that finished elsewhere:
let assert Ok(ctx) = telega.background_context(bot, chat_id:, user_id:)
let _ = dialog.refresh(ctx, registry, dialog_id: "export")
The window’s render runs again with the current state, so whatever it
reads — the session, an injected service, your own database — is re-read.
pub fn restart(
ctx ctx: bot.Context(session, error, dependencies),
registry registry: registry.FlowRegistry(
session,
error,
dependencies,
),
dialog_id dialog_id: String,
) -> Result(bot.Context(session, error, dependencies), error)
Delete the current instance and start the dialog from scratch (a repeated start command only resumes — this is the hard reset).
pub fn return_to_caller(
ctx ctx: bot.Context(session, error, dependencies),
registry registry: registry.FlowRegistry(
session,
error,
dependencies,
),
) -> Result(
#(bot.Context(session, error, dependencies), Bool),
error,
)
Re-render the dialog that opened this one, if there is one and it is still
open. False comes back when nothing opened this dialog, or the caller
has since finished — either way nothing is started.
Put it at the end of on_done to give the user back the screen they came
from.
pub fn start(
ctx ctx: bot.Context(session, error, dependencies),
registry registry: registry.FlowRegistry(
session,
error,
dependencies,
),
dialog_id dialog_id: String,
) -> Result(bot.Context(session, error, dependencies), error)
Start (or resume) an attached dialog from any handler.
Dialogs are independent: the one this is called from stays open on its own
message, and the new one gets a message of its own. What the two share is
a way back — when start is called from inside another dialog’s
handler, the new dialog remembers which dialog opened it, so its on_done
can hand control back with return_to_caller:
// in the menu dialog's on_action
"settings" -> {
let _ = dialog.start(ctx, registry, "settings")
Ok(types.Stay(state))
}
// in the settings dialog
|> dialog.on_done(fn(_state, ctx) {
use #(ctx, _returned) <- result.map(dialog.return_to_caller(ctx, registry))
ctx
})
The caller is recorded when the dialog is started; a start that only
resumes an already-open dialog leaves the way back it already had.
pub fn string_codec() -> #(
fn(String) -> String,
fn(String) -> Result(String, Nil),
)
Codec pair for a plain String state:
let #(encode, decode) = dialog.string_codec().
pub fn subdialog(
builder builder: DialogBuilder(
state,
session,
error,
dependencies,
),
sub sub: Dialog(sub_state, session, error, dependencies),
init init: fn(state, dict.Dict(String, String)) -> sub_state,
) -> DialogBuilder(state, session, error, dependencies)
Attach a built dialog as a sub-dialog, startable from any window via
StartSub(sub_id, args, state) (the sub id is the attached dialog’s id).
The sub takes over the live dialog message; its Done hands control back
to the window that started it (see on_sub_result).
Nesting is transitive: a dialog that has sub-dialogs of its own can be
attached, and its whole tree is flattened into this dialog’s namespace
(<sub>.<inner>.<window>). At runtime the entered dialogs form a stack —
each Done or boundary Back pops one level.
initbuilds the sub’s starting state from the parent state and theStartSubargs.resultexports the sub’s final state as the dict handed to the parent window’son_sub_result; prefix the keys with the sub id by convention ("address.city") to keep them collision-free.
The attached dialog’s own storage, ttl, labels and on_done are
ignored while it runs as a sub — the parent’s apply. A Back on the
sub’s first window cancels the sub (returns without a result).
pub fn toast(
ctx ctx: bot.Context(session, error, dependencies),
text text: String,
) -> Result(Nil, error.TelegaError)
Show a toast notification at the top of the chat. Same contract as
alert.
pub fn widget_store(
ctx ctx: bot.Context(session, error, dependencies),
window_id window_id: String,
widget_id widget_id: String,
) -> types.WidgetStore
Read a widget’s persistent store from inside a window render or handler
(on_action, on_text, on_done). Combine with the typed readers from
telega/dialog/widget:
let zone =
dialog.widget_store(ctx, window_id: "prefs", widget_id: "zone")
|> widget.radio_value
|> option.unwrap("hall")
Returns an empty store when the widget has no state yet. In pure render
tests seed the store first with widget.seed_store.
pub fn window(
builder builder: DialogBuilder(
state,
session,
error,
dependencies,
),
id id: String,
render render: fn(
state,
bot.Context(session, error, dependencies),
) -> types.RenderedWindow,
on_action on_action: fn(
state,
types.ActionEvent,
bot.Context(session, error, dependencies),
) -> Result(types.DialogAction(state), error),
) -> DialogBuilder(state, session, error, dependencies)
Add a window that only reacts to button presses. Text sent to it is swallowed with a re-render.
pub fn window_with_data(
builder builder: DialogBuilder(
state,
session,
error,
dependencies,
),
id id: String,
load load: fn(state, bot.Context(session, error, dependencies)) -> data,
render render: fn(
state,
data,
bot.Context(session, error, dependencies),
) -> types.RenderedWindow,
on_action on_action: fn(
state,
types.ActionEvent,
bot.Context(session, error, dependencies),
) -> Result(types.DialogAction(state), error),
) -> DialogBuilder(state, session, error, dependencies)
Add a window that renders from data it does not keep in state — the getter pattern.
load is the half that reads the world (a booking row, the user’s open
orders, a price from an injected service); render stays a pure function
of (state, data) and can be snapshot-tested by handing it data made up
on the spot. Together they are exactly the render of
window, so nothing else about the window changes.
load runs on every render of the window — the first one and each
re-render after a press — so keep it to one cheap read, and put anything
expensive in state or dependencies instead.
|> 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:,
)
To give a window with text input or widgets the same treatment, pass
with_data as their render.
pub fn window_with_input(
builder builder: DialogBuilder(
state,
session,
error,
dependencies,
),
id id: String,
render render: fn(
state,
bot.Context(session, error, dependencies),
) -> types.RenderedWindow,
on_action on_action: fn(
state,
types.ActionEvent,
bot.Context(session, error, dependencies),
) -> Result(types.DialogAction(state), error),
on_text on_text: fn(
state,
String,
bot.Context(session, error, dependencies),
) -> Result(types.DialogAction(state), error),
) -> DialogBuilder(state, session, error, dependencies)
Add a window that also accepts text input (e.g. “enter your name”).
pub fn window_with_widgets(
builder builder: DialogBuilder(
state,
session,
error,
dependencies,
),
id id: String,
render render: fn(
state,
bot.Context(session, error, dependencies),
) -> types.RenderedWindow,
on_action on_action: fn(
state,
types.ActionEvent,
bot.Context(session, error, dependencies),
) -> Result(types.DialogAction(state), error),
widgets widgets: List(
types.KeyboardWidget(state, session, error, dependencies),
),
) -> DialogBuilder(state, session, error, dependencies)
Add a window with managed keyboard widgets (see telega/dialog/widget).
Widget button rows are appended after the window’s own buttons and their
events are handled by the widgets themselves, bypassing on_action.
|> dialog.window_with_widgets(id: "fruits", render:, on_action:, widgets: [
widget.multiselect(id: "f", items: fruit_items, min: 1, max: 3,
done: "confirm"),
])
pub fn with_data(
load load: fn(state, bot.Context(session, error, dependencies)) -> data,
render render: fn(
state,
data,
bot.Context(session, error, dependencies),
) -> types.RenderedWindow,
) -> fn(state, bot.Context(session, error, dependencies)) -> types.RenderedWindow
The getter pattern as a plain render function, for the window constructors that take other handlers too:
|> dialog.window_with_input(
id: "search",
render: dialog.with_data(load: recent_queries, render: search_window),
on_action:,
on_text:,
)
pub fn with_labels(
builder builder: DialogBuilder(
state,
session,
error,
dependencies,
),
labels labels: fn(bot.Context(session, error, dependencies)) -> types.Labels,
) -> DialogBuilder(state, session, error, dependencies)
Localize engine-generated texts (stale-button notice, widget labels).
The factory receives the update’s Context, so telega_i18n.t works:
dialog.with_labels(builder, fn(ctx) { labels_from_i18n(ctx) }).
pub fn with_show_mode(
builder builder: DialogBuilder(
state,
session,
error,
dependencies,
),
mode mode: types.ShowMode,
) -> DialogBuilder(state, session, error, dependencies)
Choose when the dialog replaces its live message instead of editing it.
The default (EditLive) always edits, which is right for button presses.
A dialog with text-input windows wants ResendOnUserMessage: after the
user types, an edited window is scrolled above their message and easy to
miss, so the window is resent below it instead.
|> dialog.with_show_mode(types.ResendOnUserMessage)
pub fn with_ttl(
builder builder: DialogBuilder(
state,
session,
error,
dependencies,
),
ms ms: Int,
) -> DialogBuilder(state, session, error, dependencies)
Expire the dialog after ms milliseconds (lazy check on next event).
pub fn with_window_show_mode(
builder builder: DialogBuilder(
state,
session,
error,
dependencies,
),
window window: String,
mode mode: types.ShowMode,
) -> DialogBuilder(state, session, error, dependencies)
Override the dialog’s show mode for one window.
A dialog that edits in place is usually right, but a single window that asks the user to type wants its answer resent below what they typed:
|> dialog.with_window_show_mode(
window: "name",
mode: types.ResendOnUserMessage,
)
build() rejects an unknown window id. For one render only, a handler can
override both with types.Shown(mode, action).