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() { 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 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() -> 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.
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_sub_result(
builder builder: DialogBuilder(
state,
session,
error,
dependencies,
),
window window: String,
handler handler: fn(
state,
dict.Dict(String, String),
bot.Context(session, error, dependencies),
) -> Result(types.DialogAction(state), error),
) -> DialogBuilder(state, session, error, dependencies)
Handle the result of a sub-dialog started from window: the handler
receives the window’s state and the result dict exported by the
subdialog attachment, and returns the next action (Stay re-renders
the window with the updated state). Without a handler the window is
simply re-rendered.
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 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.
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,
result result: fn(sub_state) -> dict.Dict(String, String),
) -> 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 one level
deep: a dialog with sub-dialogs of its own cannot be attached
(NestedSubDialog).
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_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_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_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).