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

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).

  • init builds the sub’s starting state from the parent state and the StartSub args.
  • result exports the sub’s final state as the dict handed to the parent window’s on_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).

Search Document