@yaebal/morda

dialogs engine + jsx/hooks layer. declarative windows rendered into one message, automatic callback routing, a persisted navigation stack — and an optional "react-for-telegram" surface where screens are components and state is managed with hooks. the engine owns the unglamorous parts: per-key locking, stale-press detection, edit/delete fallbacks, per-user dialogs in groups, business-chat routing.

install

terminal
pnpm add @yaebal/morda

the dialogs API

the core export is dialogs(def, options?). a dialog is a flat map of named windows; each window is a render function (or a full WindowDef) returning { text, keyboard?, media?, linkPreview? }. morda encodes button ids into callback_data automatically and routes presses back to the right onClick — no manual editMessageText or callback-data wrangling. window ids are part of the type: ctx.dialog.start("mian") is a compile error.

menu.ts
import { Bot } from "@yaebal/core";
import { dialogs, switchTo, back, button, url } from "@yaebal/morda";

const bot = new Bot(process.env.BOT_TOKEN!)
  .install(dialogs({
    main: () => ({
      text: "main menu",
      keyboard: [
        [switchTo("settings →", "settings")],
        [button("ping", { id: "ping", onClick: (ctx) => ctx.answerCallbackQuery({ text: "pong" }) })],
        [url("docs", "https://example.com")],
      ],
    }),
    settings: () => ({
      text: "settings",
      keyboard: [[back("← back")]],
    }),
  }));

// window ids are typed: start("mian") is a compile error
bot.command("menu", (ctx) => ctx.dialog.start("main"));
bot.start();

ctx.dialog

installing the plugin adds ctx.dialog on every update:

navigation.ts
// ctx.dialog is available on every update after .install(dialogs(...))

// open a fresh dialog (closes any previous one, sends a new message)
await ctx.dialog.start("main", { params: { orderId: 42 }, data: { step: 1 } });

// push a window onto the stack (edits the message); params land in frame.params
await ctx.dialog.push("confirm", { orderId: 42 });

// replace the top window without growing the stack
await ctx.dialog.replace("done");

// pop one window; the result (if any) reaches the parent window's onResult.
// at the root this closes the dialog (message deleted, state dropped)
await ctx.dialog.back("2026-07-08");

// close the whole dialog from anywhere
await ctx.dialog.close();

// re-render right now / schedule one re-render after the handler
await ctx.dialog.rerender();
ctx.dialog.invalidate();

// the dialog-wide data bag (persisted; every window's render sees frame.data)
await ctx.dialog.update({ dark: true });   // merge + re-render
await ctx.dialog.setData({ dark: true });  // merge only
const data = await ctx.dialog.getData();   // read (undefined when closed)
methoddescription
start(w, { params?, data? })close any open dialog, send a new message, start a fresh stack
push(w, params?)push a window; edits the dialog message
replace(w, params?)replace the top window without growing the stack
back(result?)pop the stack; result reaches the parent's onResult. closes the dialog at the root
close()delete the message and drop the state from anywhere
rerender()re-render the current window in place, immediately
invalidate()schedule one re-render after the current handler (batched)
update(patch)merge into the dialog data bag + re-render
setData(patch)merge into data, persist, no render
getData()read the data bag (undefined when closed)
active()whether a dialog is open for this key

windows: lifecycle, input, media, formatting

a window render receives (ctx, frame)frame.params are the values passed to push/start, frame.data is the dialog-wide persisted bag. text accepts a plain string or a core format result (entities are threaded into sendMessage / editMessageText automatically).

windows.ts
import { dialogs } from "@yaebal/morda";
import { format, bold } from "@yaebal/core";

bot.install(dialogs({
  // a window is a render function…
  main: (ctx, frame) => ({
    text: format`hello ${bold(ctx.from?.first_name ?? "there")}`, // entities flow to the wire
    keyboard: [[switchTo("ask →", "ask")]],
  }),

  // …or a full def with lifecycle + free-text input
  ask: {
    render: (ctx, frame) => ({
      text: frame.data.name ? `hi, ${frame.data.name}!` : "what's your name?",
    }),
    onText: (ctx) => ctx.dialog.update({ name: ctx.text }), // commands are never routed here
    onEnter: (ctx, frame) => {},          // pushed / replaced in
    onLeave: (ctx, frame) => {},          // popped / replaced out / dialog closed
    onResult: (ctx, result, frame) => {}, // a child window's back(result)
  },

  // media windows: text becomes the caption; media↔text transitions are
  // handled for you (delete + resend where telegram refuses to edit)
  photo: () => ({
    text: "caption",
    media: { type: "photo", media: "<file_id or url or media.file(...)>" },
  }),
}));

writing dialog state from a window

every hook may write — render included. one update holds a single dialog state for the whole locked section, so setData() / update() (or a direct mutation of frame.data / frame.hooks) lands in the object the engine persists at the end of the update; the engine never clobbers a hook's write, whatever the storage adapter serializes.

render has one extra rule: it runs once per commit pass, and again when a press is routed (to locate the button that was tapped). keep its writes idempotent, put one-shot side effects in onEnter / onCommit, and note that update() / rerender() called from a render fold into one more pass instead of re-entering it — a render that unconditionally re-renders itself fails loud with a MordaError.

button helpers

helperdescription
button(label, { id, onClick? })arbitrary action button
switchTo(label, windowId, params?)pushes another window on click
back(label?, result?)pops the stack, optionally handing a result up
cancel(label?)closes the whole dialog
url(label, url)opens a url
webApp(label, url)opens a web app
copy(label, text)copies text to the clipboard
switchInline(label, query?, { currentChat? })starts an inline query

every helper takes optional icon / style — a custom-emoji id and one of "danger" | "success" | "primary" — forwarded to the inline keyboard. pass them in the options object for button / switchInline, or as a trailing { icon, style } argument on the others, e.g. button("delete", { id: "del", style: "danger" }) or url("site", "https://…", { icon: "5368324170671202286" }).

typed ambient context

window callbacks see the bare Context by default. declare the context your bot accumulates with defineDialog and render / onText / lifecycle hooks see plugin-added fields — and the install order is compiler-checked:

typed.ts
import { defineDialog, dialogs } from "@yaebal/morda";
import { session } from "@yaebal/session";
import type { Context } from "@yaebal/core";

type BotContext = Context & { session: { name: string } };

// curried, so the window map keeps its literal ids while the context stays explicit
const def = defineDialog<BotContext>()({
  profile: {
    render: (ctx) => ({ text: `hi ${ctx.session.name}` }), // typed — no casts
    onText: (ctx) => {
      ctx.session.name = ctx.text;
      return ctx.dialog.rerender();
    },
  },
});

bot
  .install(session({ initial: () => ({ name: "anon" }) }))
  .install(dialogs(def)); // installing where session is missing = compile error

on a createBot() bot the runtime context inside windows is the rich per-update class — include it in the declared context (e.g. MessageContext & { session: … }) and generated shortcuts like ctx.delete() type-check inside onText too. button<C>() accepts the same context for a helper-built button's onClick.

dialogs() options

options.ts
import { dialogs, type DialogState } from "@yaebal/morda";
import { redisStorage } from "@yaebal/sklad";
import Redis from "ioredis"; // or `createClient` from "redis" — both fit structurally

// any @yaebal/sklad StorageAdapter works — redis/sqlite/cloudflare-kv/file survive
// restarts and share dialog state across horizontally-scaled instances
const storage = redisStorage<DialogState>(new Redis(), { prefix: "bot:dialog:" });

bot.install(dialogs(def, {
  storage,                        // default: in-memory (dev only)
  prefix: "shop",                 // callback namespace — unique per install
  getKey: (ctx) => `${ctx.chat?.id}`, // default: chat id, chat:user in groups
  access: (ctx) => ctx.from?.id === ADMIN_ID, // gate who may press buttons
  maxStack: 32,                   // navigation depth cap
  events: {
    onStale: (ctx) => ctx.answerCallbackQuery({ text: "this menu expired" }),
    onAccessDenied: (ctx) => ctx.answerCallbackQuery({ text: "not for you" }),
    onClose: (ctx, result) => {}, // dialog fully closed
  },
}));
optiondefaultdescription
storageMemoryStoragewhere dialog state persists — stack, params, data, jsx hook state. any @yaebal/sklad StorageAdapter works — swap in redisStorage() (or sqlite/cloudflare-kv/file) in production and dialogs survive restarts
prefix"dlg"callback_data namespace. set a unique prefix per install when installing several dialogs on one bot
getKeychat id / chat:userstate key per update. the default gives every group member an independent dialog
accesspredicate gating presses and text input on an open dialog
maxStack32navigation depth cap; push beyond it throws
eventssilentonStale / onAccessDenied / onClose(ctx, result)

background updates

createDialogs returns the plugin plus a background handle — edit a user's open dialog from outside a handler. renders get a synthetic context (ctx.chat carries the stored chat id, ctx.from is undefined).

background.ts
import { createDialogs } from "@yaebal/morda";

const { plugin, background } = createDialogs(def, { storage });
bot.install(plugin);

// later — from a timer, queue worker, or webhook, with no incoming update:
const control = await background(bot.api, String(chatId)); // key = getKey's value
if (control) {
  await control.update({ price: 42 }); // merge data + edit the message in place
  await control.push("alert");         // or navigate
}

jsx / hooks layer

@yaebal/morda/jsx is an optional higher-level surface. screens become zero-arg components that return <Screen> trees, and react-style hooks manage state. hook state is persisted in the dialog frames — with a persistent storage it survives restarts and horizontal scaling. values must be JSON-serializable.

tsconfig.json
// tsconfig.json — point the compiler at morda's jsx transform
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@yaebal/morda"
  }
}
bot.tsx
/** @jsxImportSource @yaebal/morda */
import { Bot } from "@yaebal/core";
import {
  jsxDialogs,
  Screen, ButtonRow, Button, Url,
  useState, useNavigation,
} from "@yaebal/morda/jsx";

function SettingsScreen() {
  const nav = useNavigation();
  return (
    <Screen>
      Settings page
      <ButtonRow>
        <Button id="back" onClick={() => nav.back()}>← back</Button>
      </ButtonRow>
    </Screen>
  );
}

function MainScreen() {
  const [count, setCount] = useState(0);
  const nav = useNavigation();

  return (
    <Screen>
      {`you tapped ${count} time(s)`}
      <ButtonRow>
        <Button id="tap" onClick={() => setCount((n) => n + 1)}>tap</Button>
        <Button id="settings" onClick={() => nav.push(SettingsScreen)}>settings</Button>
      </ButtonRow>
      <ButtonRow>
        <Url url="https://example.com">docs</Url>
      </ButtonRow>
    </Screen>
  );
}

const bot = new Bot(process.env.BOT_TOKEN!)
  .install(jsxDialogs({ main: MainScreen, settings: SettingsScreen }));

bot.command("menu", (ctx) => ctx.dialog.start("main"));
bot.start();

hooks

hooks.tsx
/** @jsxImportSource @yaebal/morda */
import {
  Screen, ButtonRow, Button,
  useState, useEffect, useNavigation, useParams, useDialogData, useUser,
} from "@yaebal/morda/jsx";

function ProfileScreen() {
  const user = useUser();
  const { userId } = useParams<{ userId: number }>();   // from nav.push(Profile, { userId })
  const [data, patch] = useDialogData<{ theme?: string }>(); // dialog-wide bag
  const [profile, setProfile] = useState<Profile | null>(null);

  // effects run AFTER the render is delivered, so the load-then-show
  // pattern works: the screen shows "loading…", then edits itself.
  useEffect(() => {
    loadProfile(userId).then(setProfile);
  }, [userId]);

  return (
    <Screen onText={(ctx) => patch({ theme: ctx.text })}>
      {profile ? `${profile.name} (${data.theme ?? "light"})` : "loading…"}
      <ButtonRow>
        <Button id="hi">{`hello ${user?.first_name ?? "?"}`}</Button>
      </ButtonRow>
    </Screen>
  );
}
hookdescription
useState<T>(initial)persisted per-screen state slot. the setter batches: any number of calls in one handler produce one edit
useEffect(fn, deps?)runs after the render is delivered; deps are persisted, so [] means once per screen instance (not once per process). no cleanup
useNavigation(){ push, replace, back, close } — navigate by component: nav.push(Settings, params)
useParams<P>()params passed to push/replace/start
useDialogData<T>()[data, patch] — the dialog-wide persisted bag
useUser() / useChat()ctx.from / ctx.chat
useContext<C>()the full context — the escape hatch
useSession<S>()ctx.session; throws a clear error if @yaebal/session is missing
useTranslation(){ t, changeLanguage }; requires @yaebal/i18n

jsx components & widgets

widgets.tsx
/** @jsxImportSource @yaebal/morda */
import { Screen, Counter, Toggle, Select, Pagination, useState } from "@yaebal/morda/jsx";

function SettingsScreen() {
  const [page, setPage] = useState(1);
  return (
    <Screen>
      settings
      <Counter id="volume" min={0} max={10} />
      <Toggle id="dark">dark mode</Toggle>
      <Select
        id="lang"
        items={[{ code: "en", name: "english" }, { code: "ru", name: "русский" }]}
        itemId={(l) => l.code}
        label={(l) => l.name}
        columns={2}
      />
      <Pagination id="p" page={page} pages={5} onPage={setPage} />
    </Screen>
  );
}
componentdescription
<Screen onText? media? linkPreview?>root of every screen. onText consumes free text (return false to decline)
<ButtonRow>groups buttons into one keyboard row
<Button id onClick?>callback button; children become the label
<Url> / <WebApp> / <Copy> / <SwitchInline>the other telegram button kinds
<Counter id min? max? step? value? onChange?>− n + stepper (uncontrolled or controlled)
<Toggle id value? onChange?>on/off switch with a ☑/☐ mark
<Select id items itemId label selected? onSelect? columns?>single-choice list, chosen item marked ✓
<Pagination id page pages onPage>« ‹ n/m › » pager (controlled)

reliability & production notes

concurrent taps are serialized. a per-key lock wraps every state read-modify-write, so a double-tap in webhook mode can't corrupt the stack — the second press sees the new window and is discarded as stale (single-process; multi-process deployments need sticky routing per chat).

stale presses are detected by dialog instance. every start() mints a new intent id; presses from previous instances, from windows no longer on top, or on buttons that vanished from a fresh render are answered silently (customize via events.onStale).

telegram refusals are handled. message is not modified is swallowed (identical renders are skipped before the API call); a message the user deleted is replaced by a fresh send; deleteMessage past the 48-hour window falls back to disarming the keyboard — a dialog can always close.

the spinner always clears. answerCallbackQuery runs even when an onClick throws; answering again inside onClick (e.g. with a text) is safe.

several installs need distinct prefixes. two dialogs() installs sharing the default "dlg" prefix can misattribute each other's presses when both have an open dialog in one chat — pass prefix to each install.

hooks must be unconditional. rendering fewer hooks than the persisted slots throws (same rule as react). appending new hooks in a deploy over live dialogs is allowed.

testing

morda reaches telegram only through api.call, so a structural fake api records everything; read callback_data from the recorded keyboard and feed it back as a callback update. packages/morda/src/index.test.ts is a complete worked example.

morda.test.ts
import { Composer, Context } from "@yaebal/core";
import { MemoryStorage } from "@yaebal/session";
import { dialogs, type DialogState } from "@yaebal/morda";

// morda talks to telegram only through api.call — a structural fake records
// every call, and callback_data can be read straight off the recorded keyboard
const storage = new MemoryStorage<DialogState>();
const mw = new Composer<Context>()
  .install(dialogs(def, { storage }))
  .command("go", (ctx) => ctx.dialog.start("main"))
  .toMiddleware();

await mw(msgCtx(api, "/go", chatId), noop);
const sent = calls.find((c) => c.method === "sendMessage");
const data = sent.params.reply_markup.inline_keyboard[0][0].callback_data;
await mw(cbCtx(api, data, chatId, 100), noop); // press the button

related

@yaebal/scenes for message-per-step wizards, @yaebal/prompt for one-off questions, @yaebal/sklad for the storage adapters morda's storage option takes, and the dialog-quest example for a running bot.