recipes

standalone bot patterns you can copy into a new project and then harden for production.

terminal
# 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 test

recipe matrix

recipeexamplewhat to copy
bare-core botcore-echomiddleware, filter narrowing, format, raw typed api.call — no generated contexts
shop botcommerce-suitesession cart, i18n, pagination, typed callbacks, command menu, inbound ratelimit
wizard and support botdialog-questmorda cockpit, scenes wizard, prompt and await-style conversation
media studio botmedia-studioalbum batching, file links, media cache, svg previews, long reports
file-routed botmodular-routercommands and update handlers loaded from a routes directory
webhook botwebhook-edgefetch handler, secret token, local node adapter and optional setWebhook
scale botrunner-workersconcurrent polling with per-chat ordering and worker thread offload
testable bottesting-labcomposer factory plus actor tests for buttons, sessions and api calls
inline search botinline-searchinline query answers, offsets and chosen-result analytics
payments and stars botpayments-starstelegram stars invoice, pre-checkout, successful payment and refund
admin panel botpanellive support dashboard with media viewer and outgoing replies
broadcast botbroadcasttyped jobs, pause, resume, cancel, retry and progress
ai rich streaming botrich-messagesrich document blocks and draft streaming
mini app botmini-appHMAC + Ed25519 initData validation, Authorization: tma backend, answerWebAppQuery
scheduled jobs botcroncron expressions with per-job tz, retries, catch-up via a persisted store, ops commands
product analytics botanalyticstyped event catalog, autoTrack, ctx.identify, multiple adapters, ops commands
gradual rollout botfeature-flagspercentage rollout, kill-switch, chat-type targeting, multivariate flags, ops commands
stateful order botstate-machinetyped events driving transitions, guards, per-state onEnter hooks
admin-only botguardssafe guard + getChatMember pattern, membership caching, anonymous admin/owner checks
session-backed botsessiondirty-checked saves, file storage, ttl fields, migrations
paginated list botpaginationlazy 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.

wizard.ts
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();
prompt.ts
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.

inline-search.ts
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.

payments.ts
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.

broadcast.ts
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.

ai-rich.ts
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.

mini-app.ts
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.

cron-digest.ts
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();
cron-admin.ts
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.

analytics-track.ts
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();
analytics-admin.ts
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.

feature-flags.ts
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.

state-machine.ts
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.

guards.ts
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.

session.ts
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.

pagination.ts
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.