@monixlite/grammy-scenes 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +81 -163
  2. package/package.json +1 -1
  3. package/src/index.js +74 -32
package/README.md CHANGED
@@ -1,38 +1,33 @@
1
1
  # @monixlite/grammy-scenes
2
2
 
3
- A lightweight FSM (Finite State Machine) engine for Telegram bots built on grammY.
4
- The module implements step-based scenes with declarative step definitions, automatic transitions, session-based state management, and an extensible navigation system.
3
+ A lightweight Finite State Machine (FSM) engine for Telegram bots built on grammY. The library provides step-based scenes with declarative definitions, session-backed state, deterministic transitions, and extensible navigation.
5
4
 
6
5
  ---
7
6
 
8
7
  ## Installation
9
8
 
10
- ```bash
9
+ ```
11
10
  npm install @monixlite/grammy-scenes
12
11
  ```
13
12
 
14
- ## Requirements
15
-
16
- • Node.js >= 16
17
- • grammY >= 1.19
18
-
19
13
  ---
20
14
 
21
- ## Core Concept
15
+ ## Core Concepts
16
+
17
+ A scene is a sequence of ordered steps grouped under a shared title. Each step is defined declaratively and reacts to Telegram updates via callbacks.
22
18
 
23
- A scene is a sequence of steps united under a common title.
24
- Each step is described by an object containing:
19
+ The active scene state is stored in `ctx.session.scene`.
25
20
 
26
- scene and step identifiers
27
- • a set of callbacks reacting to Telegram events (enter, message, callbackQuery)
21
+ Each step definition contains:
28
22
 
29
- The current scene state is stored in `ctx.session.scene`.
23
+ * scene identifier (title + step)
24
+ * callbacks for lifecycle and update handling
30
25
 
31
26
  ---
32
27
 
33
28
  ## Basic Usage
34
29
 
35
- ```js
30
+ ```
36
31
  const { Bot, session } = require("grammy");
37
32
  const { Scenes } = require("@monixlite/grammy-scenes");
38
33
 
@@ -46,7 +41,7 @@ const bot = new Bot("..."); {
46
41
  };
47
42
 
48
43
 
49
- const scene = new Scenes([
44
+ const scenes = new Scenes([
50
45
  [
51
46
  {
52
47
  scene: {
@@ -54,7 +49,7 @@ const scene = new Scenes([
54
49
  step: "phone",
55
50
  },
56
51
 
57
- callbacks: {
52
+ action: {
58
53
  enter: async (ctx) => {
59
54
  await ctx.reply("• Enter your phone number:");
60
55
  },
@@ -71,26 +66,26 @@ const scene = new Scenes([
71
66
  step: "confirm",
72
67
  },
73
68
 
74
- callbacks: {
69
+ action: {
75
70
  enter: async (ctx) => {
76
71
  const { phone } = ctx.session;
77
72
 
78
73
 
79
- await ctx.reply(([
74
+ await ctx.reply([
80
75
  `• Your phone number: ${phone}`,
81
- `• Is this correct?`
82
- ]).join("\n"), {
76
+ `• Is this correct?`,
77
+ ].join("\n"), {
83
78
  reply_markup: {
84
79
  inline_keyboard: [
85
80
  [
86
81
  {
87
82
  text: "• Yes",
88
- callback_data: "confirm:yes"
83
+ callback_data: "confirm:yes",
89
84
  },
90
-
85
+
91
86
  {
92
87
  text: "• No",
93
- callback_data: "confirm:no"
88
+ callback_data: "confirm:no",
94
89
  },
95
90
  ],
96
91
  ],
@@ -98,7 +93,7 @@ const scene = new Scenes([
98
93
  });
99
94
  },
100
95
 
101
- query: [
96
+ callbacks: [
102
97
  {
103
98
  match: /^confirm:yes$/,
104
99
  handler: async (ctx) => {
@@ -106,12 +101,13 @@ const scene = new Scenes([
106
101
 
107
102
 
108
103
  await ctx.answerCallbackQuery();
109
- await ctx.reply(`• Thank you! Your phone number ${phone} has been saved.`);
104
+ await ctx.reply(`• Thank you! ${phone} saved.`);
110
105
 
111
106
 
112
107
  return "stop";
113
108
  },
114
109
  },
110
+
115
111
  {
116
112
  match: /^confirm:no$/,
117
113
  handler: async (ctx) => {
@@ -126,7 +122,7 @@ const scene = new Scenes([
126
122
  },
127
123
  ],
128
124
  ], {
129
- ["callbacks:enter:buttons:cancel"]: {
125
+ ["action:enter:extra:reply_markup:inline_keyboard:cancel"]: {
130
126
  enabled: true,
131
127
 
132
128
  component: {
@@ -144,19 +140,20 @@ bot.callbackQuery("scene:cancel", async (ctx) => {
144
140
  await ctx.editMessageReplyMarkup(null);
145
141
  await ctx.answerCallbackQuery();
146
142
 
147
- await ctx.reply("• Scene cancelled!");
143
+
144
+ await ctx.reply("• Scene cancelled");
148
145
  });
149
146
 
150
147
 
151
148
  bot.command("start", async (ctx) => {
152
- return await scene.enter(ctx, {
149
+ await scenes.enter(ctx, {
153
150
  title: "auth",
154
151
  step: "phone",
155
152
  });
156
153
  });
157
154
 
158
155
 
159
- bot.use(scene.middleware);
156
+ bot.use(scenes.middleware);
160
157
 
161
158
  bot.start();
162
159
  ```
@@ -165,20 +162,18 @@ bot.start();
165
162
 
166
163
  ## Scene Definition
167
164
 
168
- Each scene step is described by the following structure:
169
-
170
- ```js
165
+ ```
171
166
  {
172
167
  scene: {
173
168
  title: string,
174
169
  step: string,
175
170
  },
176
171
 
177
- callbacks: {
172
+ action: {
178
173
  enter?: (ctx) => Promise<void>,
179
174
  update?: (ctx) => Promise<SceneResult | void>,
180
175
  message?: (ctx) => Promise<SceneResult | void>,
181
- query?: Array<{
176
+ callbacks?: Array<{
182
177
  match: RegExp,
183
178
  handler: (ctx) => Promise<SceneResult | void>,
184
179
  }>,
@@ -186,59 +181,35 @@ Each scene step is described by the following structure:
186
181
  }
187
182
  ```
188
183
 
189
- `SceneResult` is a value that determines the next transition (described below).
184
+ `SceneResult` determines the next transition.
190
185
 
191
186
  ---
192
187
 
193
188
  ## Callbacks
194
189
 
195
- ### callbacks.enter(ctx)
196
-
197
- Triggered when entering a scene step.
198
- Used to send messages, keyboards, or initialize data.
199
-
200
- Details:
201
-
202
- • Executed automatically on step entry
203
- • Can freely use `ctx.reply`
204
- • If the cancel button option is enabled, it is automatically appended to the inline keyboard
205
-
206
- ---
207
-
208
- ### callbacks.update(ctx)
190
+ ### enter(ctx)
209
191
 
210
- Triggered on **any update** while the step is active.
192
+ Executed on step entry.
211
193
 
212
- Details:
194
+ * Used for messages and keyboards
195
+ * Cancel button can be injected automatically
213
196
 
214
- Executed before `message` and `query`
215
- • Receives the raw grammY context
216
- • Can be used for global step logic (timeouts, guards, media handling)
217
- • Return value controls navigation using standard SceneResult rules
218
-
219
- ---
197
+ ### update(ctx)
220
198
 
221
- ### callbacks.message(ctx)
199
+ Executed on any update while the step is active.
222
200
 
223
- Triggered when a text message is received while the step is active.
201
+ * Runs before `message` and `callbacks`
202
+ * Suitable for guards, timeouts, media
203
+ * Return value controls navigation
224
204
 
225
- ---
205
+ ### message(ctx)
226
206
 
227
- ### callbacks.query
207
+ Triggered on text messages during the step.
228
208
 
229
- An array of `callbackQuery` handlers.
209
+ ### callbacks[]
230
210
 
231
- Each handler has the following shape:
232
-
233
- ```js
234
- {
235
- match: RegExp,
236
- handler: async (ctx) => SceneResult
237
- }
238
- ```
239
-
240
- When `match` matches `ctx.callbackQuery.data`, the corresponding handler is executed.
241
- Its return value is processed using the same transition logic as `callbacks.message`.
211
+ CallbackQuery handlers matched by regexp.
212
+ The handler return value follows standard transition rules.
242
213
 
243
214
  ---
244
215
 
@@ -246,120 +217,73 @@ Its return value is processed using the same transition logic as `callbacks.mess
246
217
 
247
218
  ### scenes.enter(ctx, scene)
248
219
 
249
- Forces entry into a scene and step.
250
-
251
- ```js
252
- await scenes.enter(ctx, {
253
- title: "auth",
254
- step: "phone",
255
- });
256
- ```
220
+ Forces entering a scene step.
257
221
 
258
222
  Behavior:
259
223
 
260
- Writes the scene into `ctx.session.scene`
261
- Locates the step definition
262
- Executes `callbacks.enter` if present
263
- Temporarily overrides `ctx.reply` to append the cancel button
264
-
265
- ---
224
+ * Writes to `ctx.session.scene`
225
+ * Resolves step
226
+ * Executes `enter`
227
+ * Temporarily patches `ctx.reply` for cancel button injection
266
228
 
267
- ### Cancel a Scene Manually
229
+ ### Manual Termination
268
230
 
269
- A scene can be terminated manually:
270
-
271
- ```js
231
+ ```
272
232
  ctx.session.scene = null;
273
233
  ```
274
234
 
275
- After this, the middleware stops processing scene events.
276
-
277
235
  ---
278
236
 
279
237
  ## Middleware
280
238
 
281
- ```js
239
+ ```
282
240
  bot.use(session(...));
283
241
  bot.use(scenes.middleware);
284
242
  ```
285
243
 
286
244
  The middleware:
287
245
 
288
- Checks for an active scene in the session
289
- Resolves the current step
290
- Delegates updates to the appropriate callbacks
291
- • Executes step transitions
246
+ * Resolves the active step
247
+ * Dispatches updates to callbacks
248
+ * Applies transitions
292
249
 
293
- The middleware **must** be registered after the session middleware.
250
+ Must be registered after session middleware.
294
251
 
295
252
  ---
296
253
 
297
254
  ## Step Navigation
298
255
 
299
- Transitions are determined by the value returned from callbacks.
256
+ Transitions are driven by callback return values.
300
257
 
301
- ### Terminating a Scene
258
+ ### Transitions
302
259
 
303
- `"stop"`, `"exit"`, `"!"`
304
- `ctx.session.scene = null`
260
+ * `undefined`, `"next"`, `">"` → next step
261
+ * `"prev"`, `"<"` → previous step
262
+ * `"stop"`, `"exit"`, `"!"` → terminate scene
263
+ * `"^step"` → jump within current scene
264
+ * `"^scene:step"` → jump to another scene
265
+ * `{ step }` → absolute step
266
+ * `{ scene: { title, step } }` → absolute scene
305
267
 
306
- ---
307
-
308
- ### All Transitions
268
+ ### Termination
309
269
 
310
- `undefined` or `"next"` → move to the next step
311
- • `"stop" | "exit" | "!"` → terminate the scene
312
- • `"<" | "prev"` → move to the previous step
313
- • `">" | "next"` → move to the next step
314
- • `"^step"` → jump to a step within the current scene
315
- • `"^scene:step"` → jump to another scene
316
- • `{ step: "..." }` → jump to a specific step
317
- • `{ scene: { title, step } }` → jump to another scene
318
-
319
- ---
270
+ Scene termination sets:
320
271
 
321
- ### Relative Transitions
322
-
323
- • `undefined` / `"next"` / `">"` → next step
324
- • `"prev"` / `"<"` → previous step
325
-
326
- ---
327
-
328
- ### Absolute Transitions
329
-
330
- • `"^step"` → step within the current scene
331
- • `"^scene:step"` → jump to another scene
332
-
333
- ---
334
-
335
- ### Object-Based Transitions
336
-
337
- ```js
338
- return { step: "confirm" };
339
272
  ```
340
-
341
- ```js
342
- return {
343
- scene: {
344
- title: "auth",
345
- step: "phone",
346
- },
347
- };
273
+ ctx.session.scene = null;
348
274
  ```
349
275
 
350
276
  ---
351
277
 
352
278
  ## Options
353
279
 
354
- Options are passed as the second argument to `new Scenes()`.
280
+ ### action:enter:extra:reply_markup:inline_keyboard:cancel
355
281
 
356
- ### callbacks:enter:buttons:cancel
282
+ Injects a cancel button into all `enter` replies.
357
283
 
358
- Adds a cancel button to all messages sent inside `callbacks.enter`.
359
-
360
- ```js
284
+ ```
361
285
  {
362
- "callbacks:enter:buttons:cancel": {
286
+ ["action:enter:extra:reply_markup:inline_keyboard:cancel"]: {
363
287
  enabled: true,
364
288
 
365
289
  component: {
@@ -370,29 +294,23 @@ Adds a cancel button to all messages sent inside `callbacks.enter`.
370
294
  }
371
295
  ```
372
296
 
373
- Behavior:
297
+ Notes:
374
298
 
375
- Works only inside `callbacks.enter`
376
- The button is appended automatically
377
- • Scene cancellation logic must be implemented by the user
299
+ * Only affects `enter`
300
+ * Cancellation logic is user-defined
378
301
 
379
302
  ---
380
303
 
381
- ## Scenes Class
382
-
383
- ### new Scenes(scenes, options)
384
-
385
- Creates a scene manager instance.
304
+ ## API
386
305
 
387
- Parameters:
306
+ ### new Scenes(scenes, options, DEV?)
388
307
 
389
- `scenes` an array of grouped scene steps
390
- • `options` — configuration object
308
+ Creates a scene manager backed by `ScenesMiddleware`.
391
309
 
392
310
  Exports:
393
311
 
394
- `scenes.middleware` — grammY middleware
395
- `scenes.enter(ctx, scene)` — method to enter a scene
312
+ * `scenes.middleware`
313
+ * `scenes.enter(ctx, scene)`
396
314
 
397
315
  ---
398
316
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monixlite/grammy-scenes",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Scene middleware for grammY with step-based navigation",
5
5
  "main": "src/index.js",
6
6
  "type": "commonjs",
package/src/index.js CHANGED
@@ -1,59 +1,68 @@
1
1
  class ScenesMiddleware {
2
2
  constructor({
3
3
  scenes,
4
- options = {},
4
+ options,
5
+ DEV,
5
6
  }) {
6
7
  this.scenes = scenes;
7
8
 
8
- this.steps = {}; {
9
+
10
+ this.map = {}; {
9
11
  for (const group of scenes) {
10
12
  for (const item of group) {
11
13
  const { title, step } = item.scene;
12
14
 
13
15
 
14
- if (!this.steps[title]) {
15
- this.steps[title] = [];
16
+ if (!this.map[title]) {
17
+ this.map[title] = {
18
+ order: [],
19
+ steps: {},
20
+ };
16
21
  };
17
22
 
18
23
 
19
- this.steps[title].push(step);
24
+ this.map[title].order.push(step);
25
+ this.map[title].steps[step] = item;
20
26
  };
21
27
  };
22
28
  };
23
29
 
24
30
 
25
31
  this.options = {
26
- ["callbacks:enter:buttons:cancel"]: {
32
+ ["action:enter:extra:reply_markup:inline_keyboard:cancel"]: {
27
33
  enabled: true,
28
34
 
29
35
  component: {
30
- text: "Отменить",
36
+ text: "Cancel",
31
37
  callback_data: "scene:cancel",
32
38
  },
33
39
  },
34
40
 
35
41
  ...options,
36
42
  };
43
+
44
+
45
+ this.DEV = DEV;
37
46
  };
38
47
 
39
48
 
40
49
  scene = (scene) => {
41
- return this.scenes.flat().find((x) => (x.scene.title == scene?.title && x.scene.step == scene?.step));
50
+ return (this.map?.[scene?.title]?.steps?.[scene?.step] ?? null);
42
51
  };
43
52
 
44
53
  step = (scene, dir) => {
45
- const list = this.steps[scene?.title]; {
46
- if (!list) return null;
54
+ const entry = this.map?.[scene?.title]; {
55
+ if (!entry) return null;
47
56
  };
48
57
 
49
58
 
50
- const index = list.indexOf(scene?.step); {
59
+ const index = entry.order.indexOf(scene?.step); {
51
60
  if (index == -1) return null;
52
61
  };
53
62
 
54
63
 
55
- if (dir == "next") return (list[(index + 1)] ?? null);
56
- if (dir == "prev") return (list[(index - 1)] ?? null);
64
+ if (dir == "next") return (entry.order[(index + 1)] ?? null);
65
+ if (dir == "prev") return (entry.order[(index - 1)] ?? null);
57
66
 
58
67
 
59
68
  return null;
@@ -65,15 +74,19 @@ class ScenesMiddleware {
65
74
 
66
75
 
67
76
  const current = this.scene(scene); {
68
- if (!current?.callbacks?.enter) return;
77
+ if (!current?.action?.enter) return;
69
78
  };
70
79
 
71
80
 
72
- const original = ctx.reply.bind(ctx); {
81
+ const original = ctx.reply.bind(ctx);
82
+
83
+ try {
73
84
  ctx.reply = (text, extra = {}) => {
74
- const keyboard = (extra.reply_markup?.inline_keyboard || []); {
75
- if (this.options?.["callbacks:enter:buttons:cancel"]?.enabled) keyboard.push([
76
- this.options?.["callbacks:enter:buttons:cancel"]?.component,
85
+ const keyboard = [
86
+ ...(extra.reply_markup?.inline_keyboard || []),
87
+ ]; {
88
+ if (this.options?.["action:enter:extra:reply_markup:inline_keyboard:cancel"]?.enabled) keyboard.push([
89
+ this.options?.["action:enter:extra:reply_markup:inline_keyboard:cancel"]?.component,
77
90
  ]);
78
91
  };
79
92
 
@@ -81,15 +94,17 @@ class ScenesMiddleware {
81
94
  return original(text, {
82
95
  ...extra,
83
96
  reply_markup: {
97
+ ...(extra?.reply_markup || {}),
84
98
  inline_keyboard: keyboard,
85
99
  },
86
100
  });
87
101
  };
88
- };
89
102
 
90
103
 
91
- await current.callbacks.enter(ctx);
92
- ctx.reply = original;
104
+ await current.action.enter(ctx);
105
+ } finally {
106
+ ctx.reply = original;
107
+ };
93
108
  };
94
109
 
95
110
  handle = async (ctx, scene, result) => {
@@ -103,7 +118,17 @@ class ScenesMiddleware {
103
118
  const step = this.step({
104
119
  title: scene.title,
105
120
  step: scene.step,
106
- }, "prev");
121
+ }, "prev"); {
122
+ if (!step) {
123
+ if (this.DEV) {
124
+ console.warn(`[grammy-scenes] No previous step found for scene "${scene.title}" (current step: "${scene.step}")`);
125
+ };
126
+
127
+
128
+ ctx.session.scene = null;
129
+ return;
130
+ };
131
+ };
107
132
 
108
133
 
109
134
  return await this.enter(ctx, {
@@ -116,7 +141,17 @@ class ScenesMiddleware {
116
141
  const step = this.step({
117
142
  title: scene.title,
118
143
  step: scene.step,
119
- }, "next");
144
+ }, "next"); {
145
+ if (!step) {
146
+ if (this.DEV) {
147
+ console.warn(`[grammy-scenes] No next step found for scene "${scene.title}" (current step: "${scene.step}")`);
148
+ };
149
+
150
+
151
+ ctx.session.scene = null;
152
+ return;
153
+ };
154
+ };
120
155
 
121
156
 
122
157
  return await this.enter(ctx, {
@@ -167,32 +202,38 @@ class ScenesMiddleware {
167
202
 
168
203
 
169
204
  const current = this.scene(scene); {
170
- if (!current?.callbacks) return next();
205
+ if (!current?.action) return next();
171
206
  };
172
207
 
173
208
 
174
- if (ctx?.update && current.callbacks?.update) {
175
- const result = await current.callbacks.update(ctx);
209
+ if (ctx?.update && current.action?.update) {
210
+ const result = await current.action.update(ctx);
176
211
 
177
212
  return await this.handle(ctx, scene, result);
178
213
  };
179
214
 
180
215
 
181
- if (ctx?.message && current.callbacks?.message) {
216
+ if (ctx?.message && current.action?.message) {
182
217
  if (ctx?.message?.caption) ctx.message.text ??= ctx.message.caption;
183
218
 
184
219
 
185
- const result = await current.callbacks.message(ctx);
220
+ const result = await current.action.message(ctx);
186
221
 
187
222
  return await this.handle(ctx, scene, result);
188
223
  };
189
224
 
190
225
 
191
- if (ctx?.callbackQuery && current.callbacks?.query) {
226
+ if (ctx?.callbackQuery && current.action?.callbacks) {
227
+ if (!Array.isArray(current.action.callbacks)) return next();
228
+
229
+
192
230
  const data = ctx.callbackQuery.data;
193
231
 
194
232
 
195
- for (const q of current.callbacks.query) {
233
+ for (const q of current.action.callbacks) {
234
+ if (!q?.match?.test || !q?.handler) continue;
235
+
236
+
196
237
  if (q.match.test(data)) {
197
238
  const result = await q.handler(ctx);
198
239
 
@@ -208,14 +249,15 @@ class ScenesMiddleware {
208
249
 
209
250
 
210
251
  class Scenes {
211
- constructor(scenes, options = {}) {
252
+ constructor(scenes = [], options = {}, DEV = false) {
212
253
  this.instance = new ScenesMiddleware({
213
254
  scenes: scenes,
214
255
  options: options,
256
+ DEV: DEV,
215
257
  });
216
258
 
217
259
 
218
- this.middleware = this.instance.middleware;
260
+ this.middleware = this.instance.middleware.bind(this.instance);
219
261
  this.enter = this.instance.enter.bind(this.instance);
220
262
  };
221
263
  };