@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

terminal
pnpm add @yaebal/scenes

usage

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).

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

exportsignaturedescription
scenes(defs, options?) => Plugin<Context, { scene: SceneControl }>creates the scenes plugin
SceneDefinterface{ enter?: Step; steps: Step[] }
SceneContextContext & { scene: SceneControl }context type inside steps and the enter hook
SceneControlinterfacethe control object on ctx.scene
ScenesOptionsinterfaceoptions passed to scenes()
Step(ctx: SceneContext) => unknown | Promise<unknown>a single step handler

SceneControl (ctx.scene)

membertypedescription
enter(name)(name: string) => Promise<void>enter a scene; runs its enter hook and sets step to 0
next()() => voidadvance the step counter; the new step runs on the next message
leave()() => Promise<void>exit the scene; subsequent messages fall through to normal handlers
currentstring | undefinedthe active scene name, or undefined if not in a scene
stepnumberthe current step index (0-based)

ScenesOptions

fieldtyperequireddescription
storageStorageAdapter<{ scene: string; step: number }>nowhere to persist scene state. defaults to MemoryStorage (lost on restart)
getKey(ctx: Context) => string | undefinednostorage 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.

validation.ts
// 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.

switch-scene.ts
// 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

custom-storage.ts
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

cancel.ts
// 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 (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.