@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
pnpm add @yaebal/mordathe 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.
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:
// 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)| method | description |
|---|---|
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).
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
| helper | description |
|---|---|
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:
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 erroron 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
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
},
}));| option | default | description |
|---|---|---|
storage | MemoryStorage | where 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 |
getKey | chat id / chat:user | state key per update. the default gives every group member an independent dialog |
access | — | predicate gating presses and text input on an open dialog |
maxStack | 32 | navigation depth cap; push beyond it throws |
events | silent | onStale / 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).
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 — point the compiler at morda's jsx transform
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@yaebal/morda"
}
}/** @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
/** @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>
);
}| hook | description |
|---|---|
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
/** @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>
);
}| component | description |
|---|---|
<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
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.
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 buttonrelated
@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.