recipes
standalone bot patterns you can copy into a new project and then harden for production.
# run a production-shaped recipe
cp examples/webhook-edge/.env.example examples/webhook-edge/.env
pnpm --filter @yaebal/example-webhook-edge dev
# run the no-network test recipe
pnpm --filter @yaebal/example-testing-lab testrecipe matrix
| recipe | example | what to copy |
|---|---|---|
| bare-core bot | core-echo | middleware, filter narrowing, format, raw typed api.call — no generated contexts |
| shop bot | commerce-suite | session cart, i18n, pagination, typed callbacks, command menu, inbound ratelimit |
| wizard and support bot | dialog-quest | morda cockpit, scenes wizard, prompt and await-style conversation |
| media studio bot | media-studio | album batching, file links, media cache, svg previews, long reports |
| file-routed bot | modular-router | commands and update handlers loaded from a routes directory |
| webhook bot | webhook-edge | fetch handler, secret token, local node adapter and optional setWebhook |
| scale bot | runner-workers | concurrent polling with per-chat ordering and worker thread offload |
| testable bot | testing-lab | composer factory plus actor tests for buttons, sessions and api calls |
| inline search bot | inline-search | inline query answers, offsets and chosen-result analytics |
| payments and stars bot | payments-stars | telegram stars invoice, pre-checkout, successful payment and refund |
| admin panel bot | panel | live support dashboard with media viewer and outgoing replies |
| broadcast bot | broadcast | typed jobs, pause, resume, cancel, retry and progress |
| ai rich streaming bot | rich-messages | rich document blocks and draft streaming |
| mini app bot | mini-app | HMAC + Ed25519 initData validation, Authorization: tma backend, answerWebAppQuery |
| scheduled jobs bot | cron | cron expressions with per-job tz, retries, catch-up via a persisted store, ops commands |
| product analytics bot | analytics | typed event catalog, autoTrack, ctx.identify, multiple adapters, ops commands |
| gradual rollout bot | feature-flags | percentage rollout, kill-switch, chat-type targeting, multivariate flags, ops commands |
| stateful order bot | state-machine | typed events driving transitions, guards, per-state onEnter hooks |
| admin-only bot | guards | safe guard + getChatMember pattern, membership caching, anonymous admin/owner checks |
| session-backed bot | session | dirty-checked saves, file storage, ttl fields, migrations |
| paginated list bot | pagination | lazy sources, item buttons with onSelect, menu morphing and back-navigation |
bare-core bot
use core-echo when you want the honest, unassisted style: @yaebal/core with no generated contexts, raw typed api.call, and
filter-narrowed middleware. it's the example that mirrors core concepts most closely.
shop bot
use commerce-suite when you need a catalog, cart, locale switch, paginated lists and typed buttons in one bot. it is the fastest copy point for business workflows.
wizard and support bot
use dialog-quest when a bot has menus plus multi-step flows. the example shows which problems fit morda, scenes, prompt and conversation.
import { createBot, session } from "yaebal";
type Wizard = { step: "idle" | "name" | "age"; name?: string };
const bot = createBot(process.env.BOT_TOKEN!)
.install(session<Wizard>({ initial: () => ({ step: "idle" }) }));
bot.command("register", async (ctx) => {
ctx.session.step = "name";
await ctx.reply("what is your name?");
});
bot.on("message:text", async (ctx) => {
if (ctx.text.startsWith("/")) return;
if (ctx.session.step === "name") {
ctx.session.name = ctx.text;
ctx.session.step = "age";
await ctx.reply("how old are you?");
return;
}
if (ctx.session.step === "age") {
ctx.session.step = "idle";
await ctx.reply(`saved ${ctx.session.name} (${ctx.text})`);
}
});
bot.start();import { createBot } from "yaebal";
const pending = new Map<number, "name">();
const bot = createBot(process.env.BOT_TOKEN!);
bot.command("name", async (ctx) => {
pending.set(ctx.chat!.id, "name");
await ctx.reply("what should i call you?");
});
bot.on("message:text", async (ctx) => {
if (ctx.text.startsWith("/")) return;
if (pending.get(ctx.chat!.id) !== "name") return;
pending.delete(ctx.chat!.id);
await ctx.reply(`nice to meet you, ${ctx.text}`);
});
bot.start();media studio bot
use media-studio for album intake, telegram file links, cached sends, generated svg previews and reports that exceed one telegram message.
file-routed bot
use modular-router when one index file stops scaling. route files
under src/routes/commands and src/routes/on are loaded at startup and
registered on the bot, and the command menu is synced with telegram.
webhook and scale bot
use webhook-edge for fetch-style webhooks and runner-workers for high-throughput polling with cpu work moved out of the handler thread.
testable bot
use testing-lab when the bot needs real tests. it exports a composer factory, drives it with user actors and asserts outgoing api calls without touching telegram.
inline search bot
use inline-search for inline mode. it answers generated article
results, pages with next_offset, and logs chosen results.
import { createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.on("inline_query", async (ctx) => {
const query = ctx.query || "yaebal";
await ctx.answer([
{
type: "article",
id: "help",
title: `help for ${query}`,
input_message_content: { message_text: `search: ${query}` },
},
], { cache_time: 0 });
});
bot.start();payments and stars bot
use payments-stars for telegram stars: invoice creation, pre-checkout approval, successful payment handling and refunds.
import { InlineKeyboard, createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.command("buy", async (ctx) => {
await ctx.sendInvoice({
title: "coffee",
description: "support the bot with stars",
payload: "coffee",
currency: "XTR",
prices: [{ label: "coffee", amount: 1 }],
reply_markup: new InlineKeyboard().pay("pay 1 star").build(),
});
});
bot.on("pre_checkout_query", async (ctx) => {
if (ctx.invoice_payload !== "coffee") {
await ctx.answer(false, { error_message: "unknown order" });
return;
}
await ctx.answer(true);
});
bot.start();admin panel bot
use panel for a browser operator dashboard with avatars, media previews, callbacks, outgoing replies, uploads and audit events.
broadcast bot
use broadcast when one bot messages many chats: users subscribe, an admin queues a typed job, then pauses, resumes, cancels or retries it while watching progress. production notes live on queues and broadcasts.
import { createBot } from "yaebal";
const subscribers = new Set<number>([1001, 1002]);
const bot = createBot(process.env.BOT_TOKEN!);
bot.command("join", async (ctx) => {
subscribers.add(ctx.chat!.id);
await ctx.reply("subscribed");
});
bot.command("broadcast", async (ctx) => {
const text = ctx.args.join(" ") || "release is live";
for (const chatId of subscribers) {
await ctx.api.call("sendMessage", { chat_id: chatId, text });
}
await ctx.reply(`queued ${subscribers.size} deliveries`);
});
bot.start();ai rich streaming bot
use rich-messages to stream model output into telegram rich messages while keeping the grammar constrained.
import { createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.command("ask", async (ctx) => {
const question = ctx.args.join(" ") || "what is yaebal?";
const sent = await ctx.reply(`thinking about: ${question}`);
for (const text of ["reading context", "drafting answer", "yaebal keeps types flowing"]) {
// editing the *sent* message, not ctx's own — so the raw call is the right tool
await ctx.api.call("editMessageText", {
chat_id: sent.chat.id,
message_id: sent.message_id,
text,
});
}
});
bot.start();pair @yaebal/rich with llm guidance.
mini app bot
use mini-app for telegram mini apps: HMAC and Ed25519 initData
validation, an Authorization: tma backend, answerWebAppQuery, and both
direct-link and attachment-menu launch flows.
bot.command("app", (ctx) =>
ctx.reply("open the app", {
reply_markup: new InlineKeyboard()
.webApp("open app", "https://example.com/app")
.build(),
}));
// validate init data on your backend before trusting the user id.see mini apps.
scheduled jobs bot
use cron for anything time-driven: intervals, 6-field cron expressions with per-job timezones, retries with backoff, and catch-up runs after downtime via a persisted store.
import { createBot } from "yaebal";
import { cron } from "@yaebal/cron";
const bot = createBot(process.env.BOT_TOKEN!).install(
cron({
tz: "Europe/Moscow", // default zone for every job; UTC if omitted
jobs: {
digest: {
schedule: "0 9 * * *", // every day at 09:00 local — real deploys wait for this
task: async (ctx) => {
// ctx.attempt is 1 on the first try, 2 on the first retry, ...
console.log(`sending digest (attempt ${ctx.attempt})`);
},
retries: 1,
},
},
}),
);
// the plugin decorates ctx.cron — any handler can trigger/inspect a job directly
bot.command("run-digest", async (ctx) => {
const outcome = await ctx.cron.trigger("digest"); // "ran" | "skipped", bypasses the schedule
await ctx.reply(`digest: ${outcome}`);
});
bot.start();import { createBot } from "yaebal";
import { cron, cronAdmin } from "@yaebal/cron";
const bot = createBot(process.env.BOT_TOKEN!)
.install(
cron({
jobs: {
digest: { schedule: "0 9 * * *", task: () => {} },
cleanup: { schedule: 60_000, task: () => {} },
},
}),
)
// isAdmin: () => true for the demo — check ctx.from?.id against a real allow-list in production
.install(cronAdmin({ isAdmin: () => true }));
bot.start();product analytics bot
use analytics for a typed event catalog, automatic command/callback/message tracking, and pluggable adapters for wherever the events end up.
import { createBot } from "yaebal";
import { analytics, consoleAdapter, p } from "@yaebal/analytics";
// swap consoleAdapter() for postHogAdapter / plausibleAdapter / sqliteAdapter / clickhouseAdapter
const bot = createBot(process.env.BOT_TOKEN!).install(
analytics({
// event names + properties are checked against this catalog — a typo'd name or a
// missing required property is a compile error, not a silent gap in your funnel
events: {
start: true, // declared, untyped — any properties allowed
purchase: { props: p.object({ amount: p.number() }) },
},
adapters: [consoleAdapter()],
}),
);
bot.command("start", (ctx) => {
ctx.track("start", { source: "deeplink" });
return ctx.reply("hello!");
});
bot.command("buy", (ctx) => {
ctx.track("purchase", { amount: 9 });
return ctx.reply("thanks!");
});
bot.start();import { createBot } from "yaebal";
import { analytics, analyticsAdmin, memoryAdapter } from "@yaebal/analytics";
// memoryAdapter/sqliteAdapter/clickhouseAdapter all support query() — analyticsAdmin
// reads through it. posthogAdapter/plausibleAdapter don't (use their own dashboards).
const store = memoryAdapter();
const bot = createBot(process.env.BOT_TOKEN!)
.install(analytics({ adapters: [store], autoTrack: ["commands"] }))
.install(analyticsAdmin({ isAdmin: () => true, adapter: store }));
bot.command("start", (ctx) => ctx.reply("hi!"));
bot.start();gradual rollout bot
use feature-flags for percentage rollouts, kill switches, chat-type targeting and multivariate (A/B/n) flags with per-bucket overrides.
import { createBot } from "yaebal";
import { featureFlags } from "@yaebal/feature-flags";
const bot = createBot(process.env.BOT_TOKEN!).install(
featureFlags({
flags: {
checkout: {
default: "control",
// a bucket's variant is picked once, deterministically — the same user always sees the same one
variants: [
{ value: "control", weight: 50 },
{ value: "v2", weight: 50 },
],
},
},
}),
);
bot.command("checkout", async (ctx) => {
const variant = await ctx.flags.getVariant("checkout");
await ctx.reply(`checkout: ${variant}`);
});
bot.command("promote", async (ctx) => {
await ctx.flags.setGlobalOverride("checkout", "v2"); // force the winner for every bucket, no redeploy
await ctx.reply("checkout: v2 promoted for everyone");
});
bot.start();stateful order bot
use state-machine when a bot's flow is best modeled as typed events driving transitions, with a guard you can trip interactively and per-state hooks.
import { createBot, type Context } from "yaebal";
import { defineMachine, stateMachine } from "@yaebal/state-machine";
type OrderEvent = { type: "PAY" } | { type: "SHIP" } | { type: "CANCEL" };
const order = defineMachine<Context, OrderEvent>({
initial: "created",
states: {
created: {
on: {
PAY: { target: "paid" },
CANCEL: { target: "cancelled" },
},
},
paid: {
onEnter: (ctx) => ctx.send("payment received — shipping soon"),
on: {
SHIP: { target: "shipped" },
CANCEL: { target: "cancelled" },
},
},
shipped: {
onEnter: (ctx) => ctx.send("your order shipped 📦"),
},
cancelled: {
onEnter: (ctx) => ctx.send("order cancelled"),
},
},
});
const bot = createBot(process.env.BOT_TOKEN!)
.install(stateMachine(order));
bot.command("pay", (ctx) => ctx.machine.send({ type: "PAY" }));
bot.command("ship", (ctx) => ctx.machine.send({ type: "SHIP" }));
bot.command("status", (ctx) => ctx.reply(`order is ${ctx.machine.state}`));
bot.start();admin-only bot
use guards for the safe pattern: guard + getChatMember, cached membership checks, and telling anonymous admins/owners apart
from regular members.
import { createBot, type Context } from "yaebal";
import { and } from "@yaebal/filters";
import { isPrivate } from "@yaebal/guards";
const bot = createBot(process.env.BOT_TOKEN!);
bot.guard(isPrivate).command("whoami", (ctx) =>
ctx.reply(`private chat — chat.type is narrowed to "${ctx.chat.type}"`),
);
// and()/or() infer a bare predicate's ctx from the tuple element type, not the composer —
// annotate it explicitly so it isn't inferred as `never`
bot.filter(and(isPrivate, (ctx: Context) => (ctx.text?.length ?? 0) > 0), (ctx) =>
ctx.reply(`hey ${ctx.from?.first_name}, guards compose with @yaebal/filters' and/or/not`),
);
bot.start();session-backed bot
use session for session v2 in depth: dirty-checked saves so untouched state costs nothing, file storage, independent sessions keyed by chat vs. user, ttl fields and migrations.
import { clearSession, createBot, keyBy, session, ttl, unwrapTtl, type TtlValue } from "yaebal";
type Profile = { visits: number; otp?: TtlValue<string> };
const bot = createBot(process.env.BOT_TOKEN!)
// one session per *user* (covers groups and inline queries alike)
.install(session({
getKey: keyBy.user,
initial: (): Profile => ({ visits: 0 }),
}));
bot.command("me", async (ctx) => {
ctx.session.visits += 1; // unchanged sessions are never written — this one is
await ctx.reply(`visit #${ctx.session.visits}`);
});
bot.command("otp", async (ctx) => {
ctx.session.otp = ttl("1234", 60_000); // self-expiring field
await ctx.reply(`code ${unwrapTtl(ctx.session.otp)} — valid for a minute`);
});
bot.command("reset", async (ctx) => {
await clearSession(ctx); // delete from storage + fresh initial()
await ctx.reply("state wiped");
});
bot.start();paginated list bot
use pagination for lazy-loaded lists: a count + limit+1 probe
instead of loading everything, item buttons with typed onSelect payloads, and
back-navigation that morphs the same message.
import { createBot } from "yaebal";
import { pagination } from "@yaebal/pagination";
const changelog = [
"0.1 — first light",
"0.2 — plugins everywhere",
"0.3 — files and media",
"0.4 — scenes",
"0.5 — i18n",
"0.6 — playground",
];
const releases = pagination({
id: "rel",
pageSize: 3,
source: () => changelog,
line: (entry) => entry,
counter: true, // a "N/M" button between ◀ ▶ — pressing it refreshes the page
});
const bot = createBot(process.env.BOT_TOKEN!)
.install(releases.plugin());
bot.command("releases", (ctx) => releases.send(ctx));
bot.start();the full runnable example list lives on examples.