@yaebal/scenes
step-by-step wizards over multiple messages. each step handles one incoming message; calling ctx.scene.next() advances to the next step on the following message. ctx.scene.leave() exits and restores normal handler routing.
install
pnpm add @yaebal/scenesusage
define your scenes as a Record<string, SceneDef>, pass them to scenes(), and install the plugin with .install(). enter a scene from
any handler with ctx.scene.enter(name).
import { Bot } from "@yaebal/core";
import { scenes } from "@yaebal/scenes";
import type { SceneContext, SceneDef } from "@yaebal/scenes";
const defs: Record<string, SceneDef> = {
registration: {
// enter is called once when the scene starts — ask the first question here
enter: async (ctx) => {
await ctx.reply("What is your name?");
},
steps: [
// step 0: receives the name, asks for age, advances
async (ctx: SceneContext) => {
const name = ctx.text ?? "";
await ctx.reply(`Nice to meet you, ${name}! How old are you?`);
ctx.scene.next(); // advance to step 1
},
// step 1: receives the age, finishes
async (ctx: SceneContext) => {
const age = ctx.text ?? "";
await ctx.reply(`Got it — ${age} years old. Registration complete!`);
await ctx.scene.leave();
},
],
},
};
const bot = new Bot(process.env.BOT_TOKEN!)
.install(scenes(defs));
bot.command("register", (ctx) => ctx.scene.enter("registration"));
bot.start();api
| export | signature | description |
|---|---|---|
scenes | (defs, options?) => Plugin<Context, { scene: SceneControl }> | creates the scenes plugin |
SceneDef | interface | { enter?: Step; steps: Step[] } |
SceneContext | Context & { scene: SceneControl } | context type inside steps and the enter hook |
SceneControl | interface | the control object on ctx.scene |
ScenesOptions | interface | options passed to scenes() |
Step | (ctx: SceneContext) => unknown | Promise<unknown> | a single step handler |
SceneControl (ctx.scene)
| member | type | description |
|---|---|---|
enter(name) | (name: string) => Promise<void> | enter a scene; runs its enter hook and sets step to 0 |
next() | () => void | advance the step counter; the new step runs on the next message |
leave() | () => Promise<void> | exit the scene; subsequent messages fall through to normal handlers |
current | string | undefined | the active scene name, or undefined if not in a scene |
step | number | the current step index (0-based) |
ScenesOptions
| field | type | required | description |
|---|---|---|---|
storage | StorageAdapter<{ scene: string; step: number }> | no | where to persist scene state. defaults to MemoryStorage (lost on restart) |
getKey | (ctx: Context) => string | undefined | no | storage key for the update. defaults to ctx.chat?.id?.toString() |
validation loops
a step that does not call ctx.scene.next() re-runs on the next message — use this
to ask again on invalid input.
// a step that does NOT call ctx.scene.next() re-runs on the next message
// use this for validation loops
const defs: Record<string, SceneDef> = {
ask: {
enter: async (ctx) => ctx.reply("Enter a number between 1 and 10:"),
steps: [
async (ctx: SceneContext) => {
const n = Number(ctx.text);
if (n >= 1 && n <= 10) {
await ctx.reply(`You chose ${n}.`);
return ctx.scene.leave();
}
// no next() → step 0 runs again on the next message
await ctx.reply("That is not in range. Try again:");
},
],
},
};switching scenes from a step
a step can call ctx.scene.enter() to jump to a different scene entirely.
// a step can switch to another scene entirely via ctx.scene.enter()
const defs: Record<string, SceneDef> = {
a: {
steps: [
async (ctx: SceneContext) => {
await ctx.reply("switching to scene b");
await ctx.scene.enter("b"); // jumps to scene b, step 0
},
],
},
b: {
steps: [
async (ctx: SceneContext) => {
await ctx.reply("done in scene b");
return ctx.scene.leave();
},
],
},
};custom storage and key
import { scenes } from "@yaebal/scenes";
bot.install(scenes(defs, {
storage: myPersistentStorage, // StorageAdapter<{ scene: string; step: number }>
getKey: (ctx) => ctx.from?.id?.toString(), // partition by user instead of chat
}));escape hatch for commands
// while in a scene all messages are consumed — /cancel won't fire its
// command handler. check for it inside the step if you want an escape hatch.
async (ctx: SceneContext) => {
if (ctx.text === "/cancel") {
await ctx.reply("Cancelled.");
return ctx.scene.leave();
}
// normal step logic...
ctx.scene.next();
}scene messages are consumed. while a user is in a scene, every incoming message
is handled by the current step and never reaches other handlers — including command handlers.
build an in-step escape check (
steps run on messages only. the scene interceptor only fires when
advancing past the last step auto-leaves. if after calling
state is in-memory by default. pass a persistent
if (ctx.text === "/cancel")) if you need one. steps run on messages only. the scene interceptor only fires when
ctx.message is present. callback queries, inline queries, and other update types
pass through to normal handlers even while a scene is active. advancing past the last step auto-leaves. if after calling
next() the step index reaches or exceeds the length of steps, the
scene exits automatically without needing an explicit leave(). state is in-memory by default. pass a persistent
StorageAdapter to survive restarts — the same interface used by @yaebal/session.