@gramio/scenes
Version:
Scenes plugin for GramIO
518 lines (404 loc) • 25.1 kB
Markdown
# @gramio/scenes
<div align="center">
[](https://www.npmjs.org/package/@gramio/scenes)
[](https://www.npmjs.org/package/@gramio/scenes)
[](https://jsr.io/@gramio/scenes)
[](https://jsr.io/@gramio/scenes)
</div>
Multi-step, type-safe conversation scenes for [GramIO](https://gramio.dev). A `Scene` **is an `EventComposer`** — you get the full bot DSL (`.command / .callbackQuery / .hears / .on / .use / .derive / .guard / …`) plus per-step lifecycle hooks. Scenes compose into one another, so common flows (confirm-prompt, collect-contact, captcha) become reusable modules.
## Install
```bash
bun add @gramio/scenes
# or
npm install @gramio/scenes
```
---
## Mental model in 30 seconds
```
┌───────────────────────────────────────────────────────────────────┐
│ Scene = EventComposer + ordered list of Steps + lifecycle │
│ │
│ onEnter ─► [ Step 1 ] ─► [ Step 2 ] ─► … ─► onExit │
│ │ │ │
│ │ per-step: .enter / .message / .exit │
│ │ .on / .command / .callback… │
│ │ .fallback / .events │
│ │
│ ctx.scene = state, params, step navigation, enter/exit subs │
└───────────────────────────────────────────────────────────────────┘
```
- **Scene** is the top-level container. Holds state shape, params shape, and a list of steps. Has its own derives/guards/handlers that apply everywhere inside it (scene-global).
- **Step** is a sub-composer. Has lifecycle hooks (`.enter`, `.exit`, `.fallback`) **and** the full event DSL (`.on`, `.command`, `.callbackQuery`, `.hears`). One step = one screen / question / interaction point.
- **`ctx.scene`** is the per-update handle: typed `state`, typed `params`, step navigation, enter/exit sub-scenes.
State and step transitions are persisted in `Storage` keyed by user id — refresh-safe across bot restarts.
---
## 5-minute example
```typescript
import { Bot } from "gramio";
import { Scene, scenes } from "@gramio/scenes";
const greeting = new Scene("greeting")
.step("ask-name", (c) => c
.enter((ctx) => ctx.send("Hi! What's your name?"))
.on("message", (ctx) => ctx.scene.update({ name: ctx.text! })))
.step("ask-age", (c) => c
.enter((ctx) => ctx.send(`Nice, ${ctx.scene.state.name}! How old are you?`))
.on("message", (ctx) => ctx.scene.update({ age: Number(ctx.text) })))
.step("done", (c) => c.enter((ctx) => {
ctx.send(`${ctx.scene.state.name}, ${ctx.scene.state.age}. 👋`);
return ctx.scene.exit();
}));
const bot = new Bot(process.env.BOT_TOKEN!)
.extend(scenes([greeting]))
.command("start", (ctx) => ctx.scene.enter(greeting));
bot.start();
```
Notice what you **didn't** write:
- No `.state<T>()` — state shape is inferred from `update({ name, age })` calls.
- No `firstTime` check — `.enter` does that for you.
- No manual `stepId` arithmetic — `update()` auto-advances.
---
## Update flow — what happens when a message arrives
```
Telegram update
│
▼
┌─────────────────────┐
│ bot.extend(scenes) │ plugin loads ScenesStorageData for ctx.from.id
└──────────┬──────────┘
│
▼
has active scene? ─── no ──► pass to outer bot chain
│
│ yes
▼
┌─────────────────────┐
│ scene.dispatch │ • inject ctx.scene
│ │ • run scene-level derive/decorate/guard
└──────────┬──────────┘
│
▼
firstTime on this step?
│
┌───────┴────────┐
│ yes │ no
▼ ▼
.enter() scene-level handlers (.on / .command / …)
.message() then step handlers (.on / .command / …)
then .fallback() if nothing matched
│
▼
did handler call ctx.scene.update / exit / step.go ?
│
▼
persist new ScenesStorageData → wait for next update
```
- **`firstTime`** is the storage flag the plugin flips after the first dispatch on each step. Builder API uses it implicitly via `.enter`. Legacy API exposes it as `ctx.scene.step.firstTime`.
- **Passthrough**: if nothing inside the scene chain claimed the update, it falls through to the outer `bot.command / bot.on / …` (default behaviour; disable with `scenes(_, { passthrough: false })`).
---
## What goes where — the decision guide
| Concern | Put it on … | Why |
|---|---|---|
| One-time setup before any step (analytics, fetch user record) | **`scene.derive` + `scene.onEnter`** | derives run once at scene entry; `.onEnter` sees them. |
| Global escape hatch (`/cancel`, `/help`) that works in any step | **`scene.command` / `scene.callbackQuery`** | scene-level handlers run on every update inside the scene. |
| Role check / gate the whole scene | **`scene.guard`** | predicate runs once at scene entry. |
| One question / screen / interaction | **`scene.step(name, c => c…)`** | one step = one screen. Name it for `step.go("name")` jumps. |
| Send a prompt when the user lands on a step | **`c.enter` / `c.message`** | runs once on first visit; replaces `if (firstTime)` boilerplate. |
| Handle the answer to that prompt | **`c.on / c.command / c.callbackQuery / c.hears`** | per-step handlers narrow `ctx` to the right event type. |
| Catch-all if user sends something unexpected | **`c.fallback`** | runs only when no other step handler claimed the update. |
| Cleanup when leaving a step (analytics, log) | **`c.exit`** | runs once when navigating away from this step. |
| Validate-and-store a single field (prompt → schema → state) | **`scene.ask(key, schema, prompt)`** | Standard-Schema sugar over `.step`. |
| Reusable block of steps (confirmation, contact form, captcha) | **`new Scene().step(…).step(…)`** (no name) **+ `parent.extend(module)`** | nameless scene = step module. Cannot be entered directly. |
| Dive into a child flow, return with data | **`ctx.scene.enterSub(child)` + `child.exitSub({…})`** | child's exitData merges into parent's state. |
---
## State, params, exit-data — type contracts
```typescript
const checkout = new Scene("checkout")
.params<{ productId: number }>() // immutable, set at .enter(checkout, {…})
.state<{ qty: number }>() // mutable; widened by update() calls
.exitData<{ orderId: string }>() // typed return for exitSub() from this scene
.step("review", (c) => c
.enter((ctx) => {
ctx.scene.params.productId; // number — typed
ctx.scene.state.qty; // number — typed
return ctx.send("…");
})
.on("message", (ctx) =>
ctx.scene.exitSub({ orderId: "ord_42" }))); // shape enforced
await ctx.scene.enter(checkout, { productId: 7 });
```
| Type method | What it sets | Where you see it |
|---|---|---|
| `.params<T>()` | immutable args passed when entering | `ctx.scene.params` and on `ctx.scene.enter(scene, params)` |
| `.state<T>()` | mutable shape (extra to anything auto-inferred from `update()`) | `ctx.scene.state` |
| `.exitData<T>()` | what this scene returns to its parent when exiting as a sub-scene | `ctx.scene.exitSub(returnData)` typed arg |
**You rarely need `.state<T>()`.** State is auto-widened from every `ctx.scene.update({…})` call inside step handlers. Only declare it when (a) you want a field typed before any `update()` runs, (b) you're receiving fields from a sub-scene's `exitSub` (the parent-side mirror — see below).
---
## ctx.scene.update — the most-used method
```typescript
// merge state and advance to next step (most common)
await ctx.scene.update({ name: ctx.text });
// jump to a specific step (named or numeric)
await ctx.scene.update({ name: ctx.text }, { step: "confirm" });
await ctx.scene.update({}, { step: 5 });
// merge state, stay on the same step
await ctx.scene.update({ name: ctx.text }, {});
// jump but suppress the next step's .enter
await ctx.scene.update({}, { step: "review", firstTime: false });
```
**Resolution order for advancing**:
1. `options.step` set → jump there.
2. There are builder steps → walk array by index (named & numeric mixed).
3. Legacy numeric-only mode → `stepId + 1`.
4. Last step → just persist state, no transition.
---
## Reusable step modules
A `new Scene()` **without a name** is a step module. It can't be entered, only `.extend()`-ed:
```typescript
// Module: yes/no confirmation
const confirm = new Scene().step("confirm", (c) => c
.enter((ctx) => ctx.send("Are you sure?", confirmKeyboard))
.callbackQuery("yes", (ctx) => ctx.scene.step.next())
.callbackQuery("no", (ctx) => ctx.scene.exit()));
// Module: contact-info collection
const contact = new Scene()
.step("phone", (c) => c.message("Phone?")
.on("message", (ctx) => ctx.scene.update({ phone: ctx.text! })))
.step("email", (c) => c.message("Email?")
.on("message", (ctx) => ctx.scene.update({ email: ctx.text! })));
// Compose modules into real scenes
const checkout = new Scene("checkout")
.step("review", (c) => c.message("Review your cart?")
.on("message", (ctx) => ctx.scene.update({ ack: true })))
.extend(contact) // adds phone + email steps
.extend(confirm) // adds confirm step
.step("complete", (c) => c.message("Done! 🎉"));
const support = new Scene("support")
.step("describe", (c) => c.message("Describe the issue:")
.on("message", (ctx) => ctx.scene.update({ issue: ctx.text! })))
.extend(contact) // SAME module, different host
.step("submit", (c) => c.message("Ticket created!"));
```
### Merge rules
When `parent.extend(module)`:
- **Numeric step ids** are **renumbered** to fit parent.
- **Named step ids** must not collide — throws on duplicate.
- **Composer middleware** (`.derive / .use / .guard / .on / …`) merges in registration order.
- **`onEnter` / `onExit`** — parent wins; module's hooks copy only if parent has none.
- **`params` / `state` / `exitData`** — type-level intersection.
Plugin and bare-composer paths still work: `scene.extend(plugin)` / `scene.extend(composer)` skip step-merge and behave like the parent `Composer.extend`.
### Module enforcement
Trying to register a module via `scenes([module])` throws — modules must be `.extend()`-ed into a named scene.
---
## Sub-scenes — `enterSub` / `exitSub`
Sub-scenes are for nesting flows. Parent pauses, child runs, child returns data → parent resumes at the same step with merged state.
```
┌── parent scene ──────────────────────────────────────────┐
│ │
│ step "ask-address" │
│ .enter ──► ctx.scene.enterSub(pickAddress) │
│ │ │
│ ▼ │
│ ┌── pickAddress (child) ──────────────────────────┐ │
│ │ step "ask" │ │
│ │ .enter ──► "Enter your address" │ │
│ │ .on(message) ──► exitSub({ address }) │ │
│ └────────────────────────────┬────────────────────┘ │
│ │ merges { address } │
│ │ into parent.state │
│ ▼ │
│ step "ask-address" RESUMES with firstTime=false │
│ .on(message) ──► sees ctx.scene.state.address │
│ advances via ctx.scene.step.next() │
└──────────────────────────────────────────────────────────┘
```
```typescript
const pickAddress = new Scene("pickAddress")
.exitData<{ address: string }>() // child declares return shape
.step("ask", (c) => c
.enter((ctx) => ctx.send("Enter your address:"))
.on("message", (ctx) => {
if (!ctx.text) return ctx.send("Send text please");
return ctx.scene.exitSub({ address: ctx.text });
}));
const checkout = new Scene("checkout")
.state<{ address: string }>() // parent declares what it expects
.step("ask-address", (c) => c
.enter((ctx) => ctx.scene.enterSub(pickAddress))
.on("message", (ctx) => {
// child's exitSub merged { address } into ctx.scene.state.
// The same update is re-dispatched here with firstTime=false;
// advance only when we see the field arrive.
if (ctx.scene.state.address) return ctx.scene.step.next();
}))
.step("confirm", (c) => c
.enter((ctx) => ctx.send(`Deliver to ${ctx.scene.state.address}?`))
.on("message", (ctx) => ctx.scene.exit()));
bot
.extend(scenes([checkout, pickAddress]))
.command("checkout", (ctx) => ctx.scene.enter(checkout));
```
**Quirk to know**: when the child `exitSub`s, the parent's step resumes at the same step with `firstTime = false`. The triggering update is re-dispatched into the parent, so the parent's `.on("message", …)` handler fires. Always guard your "resumed" branch by checking the field the child injected (see `if (ctx.scene.state.address)` above).
Sub-scenes nest arbitrarily deep — each `exitSub` unwinds one level. Calling `exitSub` on a scene entered normally (not via `enterSub`) behaves like `exit()`.
### Typing the sub-scene contract
The type-level connection between `child.exitData<T>()` and `parent.state` is **not automatic** — write both. The pattern:
1. Child: `.exitData<{ field: T }>()` ← types `ctx.scene.exitSub(returnData)` to require that shape.
2. Parent: `.state<{ field: T }>()` ← types `ctx.scene.state.field` in the resume branch.
---
## Validated input — `.ask(key, schema, prompt)`
Sugar over `.step` for the very common prompt → validate → store pattern. Uses [Standard Schema](https://standardschema.dev/) (Zod, Sury, Valibot, ArkType, …).
```typescript
import { z } from "zod";
const profile = new Scene("profile")
.ask("name", z.string().min(2), "Enter your name (≥ 2 chars):")
.ask("age", z.coerce.number().int().min(0), "Enter your age:", {
onInvalidInput: (issues) => `❌ ${issues[0].message}\nTry again:`,
})
.step("done", (c) => c.enter((ctx) =>
ctx.send(`Saved: ${ctx.scene.state.name}, ${ctx.scene.state.age}`)));
```
`ctx.scene.state.name` and `ctx.scene.state.age` are inferred from the schema output types — no `.state<T>()` needed.
---
## Step builder — full reference
```typescript
new Scene("greet").step("intro", (c) => c
.events(["message", "callback_query"]) // optional — narrow accepted events
.enter((ctx) => ctx.send("Hi!")) // runs once on first visit
.command("skip", (ctx) => ctx.scene.step.next())
.callbackQuery("back", (ctx) => ctx.scene.step.previous())
.on("message", (ctx) => ctx.scene.update({ name: ctx.text! }))
.fallback((ctx) => ctx.send("I didn't understand that"))
.exit((ctx) => analytics.track("intro_completed")));
```
| Method | Runs when |
| ------------------------ | ------------------------------------------------------------------------- |
| `.enter(handler)` | First visit to this step (replaces `if (firstTime)`) |
| `.message(text\|fn)` | Sugar — `c.message("Hi")` ≡ `c.enter(ctx => ctx.send("Hi"))` |
| `.exit(handler)` | Leaving this step (`step.next/previous/go`, `scene.exit`, `reenter`) |
| `.fallback(handler)` | No other handler in this step claimed the update |
| `.events([...])` | Narrow accepted events (default: `message` + `callback_query`) |
| `.command(name, fn)` | `/name` while in this step |
| `.callbackQuery(t, fn)` | Button click (string / RegExp / `CallbackData`) |
| `.hears(t, fn)` | Text match (string / array / RegExp / predicate) |
| `.on(event, fn)` | Generic event handler |
| `.use/.derive/.guard/…` | Standard composer middleware, scoped to this step |
| `.updates<T>()` | Type-only — declare state contribution (rarely needed; auto-inferred) |
### Step ids: numeric or named
```typescript
new Scene("flow")
.step((c) => c.message("step 0")) // numeric id 0
.step("review", (c) => c.message("review")) // named id "review"
.step((c) => c.message("step 2")); // numeric id 2 (numbering continues)
```
Navigate by either name or number:
```typescript
ctx.scene.step.next(); // → next entry in the list
ctx.scene.step.previous(); // → previous entry
ctx.scene.step.go("review"); // → named jump
ctx.scene.step.go(2); // → numeric jump
```
---
## Scene lifecycle — `onEnter` / `onExit`
```typescript
new Scene("checkout")
.derive(async (ctx) => ({ user: await db.users.find(ctx.from!.id) }))
.onEnter((ctx) => analytics.track("checkout_start", { userId: ctx.user.id }))
.onExit((ctx) => analytics.track("checkout_end"))
.step("review", (c) => c.message("Order looks good?")
.on("message", (ctx) => ctx.scene.update({ ack: true })))
.step("done", (c) => c.message("Done!"));
```
- **`.onEnter(handler)`** fires once when the user enters the scene. Runs **after** scene-level `.derive()` / `.decorate()` apply, so derived ctx fields (`ctx.user`, `ctx.config`, …) are visible. Does NOT re-fire on `step.go()` transitions within the scene.
- **`.onExit(handler)`** fires once when the user leaves the scene via `ctx.scene.exit()`, `ctx.scene.exitSub()`, or `ctx.scene.reenter()`, before storage cleanup.
---
## `ctx.scene` API reference
```typescript
ctx.scene.state // mutable state (typed)
ctx.scene.params // immutable params (typed)
ctx.scene.step.id // current step id (string | number)
ctx.scene.step.previousId // previous step id (string | number)
ctx.scene.step.firstTime // first dispatch on this step?
ctx.scene.step.next() // advance
ctx.scene.step.previous() // back
ctx.scene.step.go(id, firstTime?) // jump (accepts string | number)
ctx.scene.update(state, options?) // merge state, optionally jump
// options.step: string | number
// options.firstTime: boolean
ctx.scene.enter(scene, params?) // open a top-level scene
ctx.scene.exit() // leave current scene
ctx.scene.reenter(params?) // exit + re-enter, clean state
ctx.scene.enterSub(scene, params?) // dive into a sub-scene
ctx.scene.exitSub(returnData?) // return to parent, merge data
```
`scene.enter / scene.enterSub` enforce params shape at the call site if the scene declared `.params<T>()`. `scene.exitSub` enforces returnData shape if the scene declared `.exitData<T>()`.
---
## Plugin registration
```typescript
bot.extend(scenes([greeting, checkout, support], {
storage: redisStorage({ host: "localhost", port: 6379 }),
passthrough: true, // default
}));
```
| Option | Type | Default | Description |
| ------------- | --------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
| `storage` | `Storage` | `inMemoryStorage()` | Where scene state is persisted (in-memory loses state on restart; use Redis / file / etc. for production). |
| `passthrough` | `boolean` | `true` | If `true`, updates the active step doesn't claim fall through to outer bot handlers (`bot.command`, `bot.on`). Set `false` to make scenes greedy. |
### `scenesDerives` — when you need `ctx.scene` before the router
Use `scenesDerives` to inject `ctx.scene.enter` / `ctx.scene.current` into handlers that run **before** the scenes router (e.g. a global onboarding gate):
```typescript
import { scenes, scenesDerives } from "@gramio/scenes";
import { inMemoryStorage } from "@gramio/storage";
const storage = inMemoryStorage(); // share the SAME storage across both
bot
.extend(scenesDerives([myScene], { storage, withCurrentScene: true }))
.on("message", (ctx) => {
if (ctx.scene.current?.is(myScene)) {
// ctx.scene.current.state typed to myScene's state
}
})
.extend(scenes([myScene], { storage }));
```
---
## Storage data shape
```typescript
interface ScenesStorageData {
name: string; // scene name
params: unknown; // immutable, passed at enter()
state: unknown; // mutable, updated via update()
stepId: string | number; // current step
previousStepId: string | number;
firstTime: boolean; // first dispatch on current step
entered?: boolean; // true once onEnter has fired
parentStack?: ParentSceneFrame[]; // set by enterSub() — supports N-level nesting
}
interface ParentSceneFrame {
name: string;
params: unknown;
state: unknown;
stepId: string | number;
previousStepId: string | number;
parentStack?: ParentSceneFrame[];
}
```
Storage key: `@gramio/scenes:<userId>`. Schema changes are back-compat (new optional fields only) so persistent stores survive upgrades.
---
## Legacy step API (still supported)
The original `.step("message", handler)` form keeps working — useful for one-shot steps and existing code:
```typescript
const greeting = new Scene("greeting")
.step("message", (ctx) => {
if (ctx.scene.step.firstTime) return ctx.send("What's your name?");
return ctx.scene.update({ name: ctx.text });
});
```
Disambiguation when calling `.step(...)`:
| Form | Resolved as |
| ---------------------------------------------------------- | ------------------------------------------ |
| `.step((c) => …)` | Builder step, numeric id (autoincrement) |
| `.step("any-name", (c) => …)` | Builder step, named id |
| `.step("message" \| "callback_query" \| …, handler)` | Legacy event-filtered step |
| `.step(["message", "callback_query"], handler)` | Legacy event-filtered step (multi-event) |
Reserved first-argument names are the Telegram event taxonomy (`message`, `callback_query`, `channel_post`, `inline_query`, …). Don't name a builder step the same as an event — TS will pick the legacy overload and your `c.enter(...)` will fail to type-check.
You can mix legacy and builder steps in the same scene; they coexist on the same step list.
---
## Full API reference & guides
See the [official plugin docs](https://gramio.dev/plugins/official/scenes).