npm package: @monixlite/grammy-scenes
Description: Scene middleware for grammY with step-based navigation
Author: monixlite
Latest version: 1.4.0

# @monixlite/grammy-scenes

A lightweight finite state machine (FSM) engine for Telegram bots built on grammY. The library provides declarative, step-based scenes with deterministic transitions, session-backed state, and minimal runtime overhead.

The goal of the library is to stay simple and predictable: no hidden magic, no implicit state, and no tight coupling to bot logic. Everything is explicit and driven by return values.

---

## Installation

```
npm install @monixlite/grammy-scenes
```

---

## Core Concepts

### Scene

A scene is a logical flow (for example: `auth`, `profile_edit`, `checkout`).

### Step

A step is a single state inside a scene. Steps are ordered in the same order they are declared.

### FSM

Internally, the library builds a finite state machine:

* scene title → ordered step list
* step → action handlers

---

## Basic Usage

```js
const { Bot, session } = require("grammy");
const { Scenes } = require("@monixlite/grammy-scenes");


const bot = new Bot("..."); {
    bot.use(session({
        initial: () => ({
            scene: null,
        }),
    }));
};


const scenes = new Scenes([
    [
        {
            scene: {
                title: "auth",
                step: "phone",
            },

            action: {
                enter: async (ctx) => {
                    await ctx.reply("• Enter your phone number:");
                },

                message: async (ctx) => {
                    ctx.session.phone = ctx.message.text.trim();
                },
            },
        },

        {
            scene: {
                title: "auth",
                step: "confirm",
            },

            action: {
                enter: async (ctx) => {
                    const { phone } = ctx.session;


                    await ctx.reply([
                        `• Your phone number: ${phone}`,
                        `• Is this correct?`,
                    ].join("\n"), {
                        reply_markup: {
                            inline_keyboard: [
                                [
                                    {
                                        text: "• Yes",
                                        callback_data: "confirm:yes",
                                    },
                                    
                                    {
                                        text: "• No",
                                        callback_data: "confirm:no",
                                    },
                                ],
                            ],
                        },
                    });
                },

                callbacks: [
                    {
                        match: /^confirm:(yes|no)$/,
                        handler: async (ctx, match) => {
                            await ctx.answerCallbackQuery();


                            switch (match[1]) {
                                case "yes": {
                                    const { phone } = ctx.session;


                                    await ctx.reply(`• Thank you! Your phone number ${phone} has been saved.`);


                                    return "stop";
                                };

                                default: {
                                    return "^phone";
                                };
                            };
                        },
                    },
                ],
            },
        },
    ],
], {
    ["inline_keyboard:scene:cancel"]: {
        enabled: true,

        component: {
            text: "• Cancel scene •",
            callback_data: "scene:cancel",
        },
    },
});


bot.callbackQuery("scene:cancel", async (ctx) => {
    ctx.session.scene = null;


    await ctx.editMessageReplyMarkup(null);
    await ctx.answerCallbackQuery();


    await ctx.reply("• Scene cancelled");
});


bot.command("start", async (ctx) => {
    return await scenes.enter(ctx, {
        title: "auth",
        step: "phone",
    });
});


bot.use(scenes.middleware);

bot.start();
```

---

## Scene Definition

```js
{
    scene: {
        title: string,
        step: string,
    },

    action: {
        enter?: (ctx) => Promise<void>,

        update?: (ctx) => Promise<SceneResult | void>,
        message?: (ctx) => Promise<SceneResult | void>,
        callbacks?: Array<{
            match: RegExp,
            handler: (
                ctx,
                match: RegExpMatchArray,
            ) => Promise<SceneResult | void>,
        }>,
    },
}
```

`SceneResult` determines the next transition.

---

## Callbacks

### enter(ctx)

Executed on step entry.

* Used for messages and keyboards
* Cancel button can be injected automatically

### update(ctx)

Executed on any update while the step is active.

* Runs before `message` and `callbacks`
* Suitable for guards, timeouts, media
* Return value controls navigation

### message(ctx)

Triggered on text messages during the step.

### callbacks[]

CallbackQuery handlers matched by regexp.
The handler return value follows standard transition rules.

---

## Scene Control

### scenes.enter(ctx, scene)

Forces entering a scene step.

Behavior:

* Writes to `ctx.session.scene`
* Resolves step
* Executes `enter`
* Temporarily patches `ctx.reply` for cancel button injection

### Manual Termination

```js
ctx.session.scene = null;
```

---

## Middleware

```js
bot.use(session(...));
bot.use(scenes.middleware);
```

The middleware:

* Resolves the active step
* Dispatches updates to callbacks
* Applies transitions

Must be registered after session middleware.

---

## Step Navigation

Transitions are driven by callback return values.

### Transitions

* `undefined`, `"next"`, `">"` → next step
* `"prev"`, `"<"` → previous step
* `"stop"`, `"exit"`, `"!"` → terminate scene
* `"^step"` → jump within current scene
* `"^scene:step"` → jump to another scene
* `{ step }` → absolute step
* `{ scene: { title, step } }` → absolute scene

### Termination

Scene termination sets:

```js
ctx.session.scene = null;
```

---

## Options

Options are passed to the `Scenes` constructor and stored internally.

```js
const scenes = new Scenes(scenes, options, DEV);
```

### action:enter:redefinition:reply

Controls whether `ctx.reply` is temporarily patched during `enter`.

When enabled, all `ctx.reply` calls inside `enter`:

* preserve existing inline keyboards
* automatically append the cancel button (if enabled)

```js
{
    ["action:enter:redefinition:reply"]: {
        enabled: true,
    },
}
```

Disable this option if you want full manual control over `ctx.reply` behavior inside `enter`.

### inline_keyboard:scene:cancel

Automatically injects a cancel button into **all `enter` replies**.

```js
{
    ["inline_keyboard:scene:cancel"]: {
        enabled: true,

        component: {
            text: "Cancel",
            callback_data: "scene:cancel",
        },
    },
}
```

Notes:

* Only affects `enter(ctx)` replies
* Does not implement cancellation logic
* The handler for `scene:cancel` is user-defined

---

## Accessing Options at Runtime

Options passed to `Scenes` are stored internally and can be accessed via `scenes.option(name)`.

This is useful when you need to reuse option configuration (for example, the cancel button component) outside of the scene lifecycle.

Example:

```js
const cancel = scenes.option("inline_keyboard:scene:cancel");

if (cancel?.enabled) {
    console.log(cancel.component);
    // { text: 'Cancel', callback_data: 'scene:cancel' }
};
```

Notes:

* Returns `null` if the option is not defined
* The returned object is not cloned; treat it as read-only

---

## API

### new Scenes(scenes, options, DEV?)

Creates a scene manager backed by `ScenesMiddleware`.

Exports:

* `scenes.middleware`
* `scenes.enter(ctx, scene)`
* `scenes.option(name)`

### DEV mode

When `DEV` is set to `true`, the middleware prints warnings to the console if:

* a transition points to a missing step
* there is no next / previous step in a scene

This mode is intended for development and debugging only.

---

## License

MIT