getting started
from botfather to a tested deploy path without learning the whole framework first.
1. create a bot token
open @BotFather, send /newbot, pick a name and username, then copy the token. keep it out of git.
2. scaffold or install
for a real project, scaffold. for a scratch file, install the meta package directly.
pnpm create yaebalpnpm add yaebal3. set environment variables
use your runtime or host secret store. locally, a plain .env is enough.
BOT_TOKEN=123456789:replace_me
WEBHOOK_SECRET=change_this_before_deploy.env. bun and deno read it automatically. node needs an
explicit flag (or a package like dotenv) — see below. the run commands in step 5
instead pass BOT_TOKEN straight on the command line, which needs no loader at all;
either approach works, pick one per project.# bun and deno load .env automatically. node needs an explicit flag:
node --env-file=.env dist/bot.js
tsx --env-file=.env bot.ts4. your first bot
this example is runnable in the playground and in node/bun/deno. it uses the meta package yaebal, so handlers get generated context shortcuts.
import { createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.command("start", (ctx) => ctx.reply("hello from yaebal"));
bot.on("message:text", (ctx) => {
console.log("got message:", ctx.text);
ctx.reply(`you said: ${ctx.text}`);
});
bot.onStart((me) => console.log("bot ready"));
bot.start();running it locally should print bot ready to the console once getMe() succeeds, and any message you send the bot gets echoed back as you said: ….
"type": "module". use explicit .js specifiers for local typescript imports that are compiled to node esm.5. run it
BOT_TOKEN=123:abc pnpm tsx bot.tson Windows, set the variable in its own step first:
$env:BOT_TOKEN="123:abc"; pnpm tsx bot.ts6. add a button
use InlineKeyboard for markup and callbackData() instead of hand-rolled
string formats.
import { InlineKeyboard, callbackData, createBot } from "yaebal";
const choice = callbackData("choice", { value: String });
const bot = createBot(process.env.BOT_TOKEN!);
bot.command("start", (ctx) =>
ctx.reply("pick a path", {
reply_markup: new InlineKeyboard()
.text("ship it", choice.pack({ value: "ship" }))
.text("wait", choice.pack({ value: "wait" }))
.build(),
}),
);
bot.callbackQuery(choice.pattern, async (ctx) => {
const data = choice.unpack(ctx.callbackQuery.data ?? "");
await ctx.answer(data?.value === "ship" ? "shipping" : "holding");
await ctx.editText(`status: ${data?.value ?? "unknown"}`);
});
bot.start();7. add state
session() adds ctx.session to the downstream context type. no declaration
merging, no casts.
import { createBot, session } from "yaebal";
type Session = { count: number };
const bot = createBot(process.env.BOT_TOKEN!)
.install(session<Session>({ initial: () => ({ count: 0 }) }));
bot.command("count", async (ctx) => {
ctx.session.count += 1;
await ctx.reply(`count: ${ctx.session.count}`);
});
bot.hears("reset", async (ctx) => {
ctx.session.count = 0;
await ctx.reply("count reset");
});
bot.start();8. test before deploy
as a bot grows past a single file, split the bot's definition from the entrypoint that starts it:
a module that calls bot.start() as a side effect can't be safely imported
by a test.
// bot.ts — export the bot, but don't call start() here. that keeps this
// module import-safe: tests (and any other tool) can load it without opening
// a real connection to Telegram.
import { createBot } from "yaebal";
export const bot = createBot(process.env.BOT_TOKEN!);
bot.command("start", (ctx) => ctx.reply("hello from yaebal"));
bot.on("message:text", (ctx) => ctx.reply("you said: " + ctx.text));// main.ts — the entrypoint you actually run.
import { bot } from "./bot.js";
await bot.start();@yaebal/test drives your real bot with virtual users and records outgoing api calls.
no telegram token is used in ci.
pnpm add -D @yaebal/test vitestimport { createTestEnv } from "@yaebal/test";
import { expect, test } from "vitest";
import { bot } from "./bot.js";
test("/start replies", async () => {
const env = createTestEnv(bot);
const user = env.createUser({ firstName: "linia" });
await user.sendCommand("start");
expect(env.lastApiCall("sendMessage")?.params?.text).toBe("hello from yaebal");
});9. deploy
start with polling for development. use webhooks for serverless and edge, or @yaebal/runner for concurrent long polling under real traffic.
import { webhook } from "yaebal";
import { bot } from "./bot.js";
// any fetch-style runtime: bun, deno, cloudflare workers, vercel edge.
export default {
fetch: webhook(bot, { secretToken: process.env.WEBHOOK_SECRET }),
};pnpm build
BOT_TOKEN=123:abc node dist/main.jsnext
- core concepts — the composer, derive/decorate, filter queries
- contexts — the auto-generated context layer (the killer feature)
- plugins — sessions, keyboards, scenes, i18n and more
- production — rate limits, webhooks, queues, secrets and observability