@monixlite/grammy-scenes 1.2.2 → 1.4.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 +116 -47
  2. package/package.json +1 -1
  3. package/src/index.js +104 -37
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # @monixlite/grammy-scenes
2
2
 
3
- A lightweight Finite State Machine (FSM) engine for Telegram bots built on grammY.
4
- The library provides step-based scenes with declarative definitions, session-backed state, deterministic transitions, and extensible navigation.
3
+ 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.
4
+
5
+ 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.
5
6
 
6
7
  ---
7
8
 
@@ -11,29 +12,30 @@ The library provides step-based scenes with declarative definitions, session-bac
11
12
  npm install @monixlite/grammy-scenes
12
13
  ```
13
14
 
14
- ## Requirements
15
-
16
- * Node.js >= 16
17
- * grammY >= 1.19
18
-
19
15
  ---
20
16
 
21
17
  ## Core Concepts
22
18
 
23
- 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.
19
+ ### Scene
20
+
21
+ A scene is a logical flow (for example: `auth`, `profile_edit`, `checkout`).
24
22
 
25
- The active scene state is stored in `ctx.session.scene`.
23
+ ### Step
26
24
 
27
- Each step definition contains:
25
+ A step is a single state inside a scene. Steps are ordered in the same order they are declared.
28
26
 
29
- * scene identifier (title + step)
30
- * callbacks for lifecycle and update handling
27
+ ### FSM
28
+
29
+ Internally, the library builds a finite state machine:
30
+
31
+ * scene title → ordered step list
32
+ * step → action handlers
31
33
 
32
34
  ---
33
35
 
34
36
  ## Basic Usage
35
37
 
36
- ```
38
+ ```js
37
39
  const { Bot, session } = require("grammy");
38
40
  const { Scenes } = require("@monixlite/grammy-scenes");
39
41
 
@@ -55,7 +57,7 @@ const scenes = new Scenes([
55
57
  step: "phone",
56
58
  },
57
59
 
58
- callbacks: {
60
+ action: {
59
61
  enter: async (ctx) => {
60
62
  await ctx.reply("• Enter your phone number:");
61
63
  },
@@ -72,7 +74,7 @@ const scenes = new Scenes([
72
74
  step: "confirm",
73
75
  },
74
76
 
75
- callbacks: {
77
+ action: {
76
78
  enter: async (ctx) => {
77
79
  const { phone } = ctx.session;
78
80
 
@@ -88,6 +90,7 @@ const scenes = new Scenes([
88
90
  text: "• Yes",
89
91
  callback_data: "confirm:yes",
90
92
  },
93
+
91
94
  {
92
95
  text: "• No",
93
96
  callback_data: "confirm:no",
@@ -98,27 +101,28 @@ const scenes = new Scenes([
98
101
  });
99
102
  },
100
103
 
101
- query: [
104
+ callbacks: [
102
105
  {
103
- match: /^confirm:yes$/,
104
- handler: async (ctx) => {
105
- const { phone } = ctx.session;
106
+ match: /^confirm:(yes|no)$/,
107
+ handler: async (ctx, match) => {
108
+ await ctx.answerCallbackQuery();
106
109
 
107
110
 
108
- await ctx.answerCallbackQuery();
109
- await ctx.reply(`• Thank you! ${phone} saved.`);
111
+ switch (match[1]) {
112
+ case "yes": {
113
+ const { phone } = ctx.session;
110
114
 
111
115
 
112
- return "stop";
113
- },
114
- },
115
- {
116
- match: /^confirm:no$/,
117
- handler: async (ctx) => {
118
- await ctx.answerCallbackQuery();
116
+ await ctx.reply(`• Thank you! Your phone number ${phone} has been saved.`);
119
117
 
120
118
 
121
- return "^phone";
119
+ return "stop";
120
+ };
121
+
122
+ default: {
123
+ return "^phone";
124
+ };
125
+ };
122
126
  },
123
127
  },
124
128
  ],
@@ -126,7 +130,7 @@ const scenes = new Scenes([
126
130
  },
127
131
  ],
128
132
  ], {
129
- "callbacks:enter:buttons:cancel": {
133
+ ["inline_keyboard:scene:cancel"]: {
130
134
  enabled: true,
131
135
 
132
136
  component: {
@@ -150,7 +154,7 @@ bot.callbackQuery("scene:cancel", async (ctx) => {
150
154
 
151
155
 
152
156
  bot.command("start", async (ctx) => {
153
- await scenes.enter(ctx, {
157
+ return await scenes.enter(ctx, {
154
158
  title: "auth",
155
159
  step: "phone",
156
160
  });
@@ -166,20 +170,24 @@ bot.start();
166
170
 
167
171
  ## Scene Definition
168
172
 
169
- ```
173
+ ```js
170
174
  {
171
175
  scene: {
172
176
  title: string,
173
177
  step: string,
174
178
  },
175
179
 
176
- callbacks: {
180
+ action: {
177
181
  enter?: (ctx) => Promise<void>,
182
+
178
183
  update?: (ctx) => Promise<SceneResult | void>,
179
184
  message?: (ctx) => Promise<SceneResult | void>,
180
- query?: Array<{
185
+ callbacks?: Array<{
181
186
  match: RegExp,
182
- handler: (ctx) => Promise<SceneResult | void>,
187
+ handler: (
188
+ ctx,
189
+ match: RegExpMatchArray,
190
+ ) => Promise<SceneResult | void>,
183
191
  }>,
184
192
  },
185
193
  }
@@ -202,7 +210,7 @@ Executed on step entry.
202
210
 
203
211
  Executed on any update while the step is active.
204
212
 
205
- * Runs before `message` and `query`
213
+ * Runs before `message` and `callbacks`
206
214
  * Suitable for guards, timeouts, media
207
215
  * Return value controls navigation
208
216
 
@@ -210,7 +218,7 @@ Executed on any update while the step is active.
210
218
 
211
219
  Triggered on text messages during the step.
212
220
 
213
- ### query[]
221
+ ### callbacks[]
214
222
 
215
223
  CallbackQuery handlers matched by regexp.
216
224
  The handler return value follows standard transition rules.
@@ -232,7 +240,7 @@ Behavior:
232
240
 
233
241
  ### Manual Termination
234
242
 
235
- ```
243
+ ```js
236
244
  ctx.session.scene = null;
237
245
  ```
238
246
 
@@ -240,7 +248,7 @@ ctx.session.scene = null;
240
248
 
241
249
  ## Middleware
242
250
 
243
- ```
251
+ ```js
244
252
  bot.use(session(...));
245
253
  bot.use(scenes.middleware);
246
254
  ```
@@ -273,7 +281,7 @@ Transitions are driven by callback return values.
273
281
 
274
282
  Scene termination sets:
275
283
 
276
- ```
284
+ ```js
277
285
  ctx.session.scene = null;
278
286
  ```
279
287
 
@@ -281,14 +289,40 @@ ctx.session.scene = null;
281
289
 
282
290
  ## Options
283
291
 
284
- ### callbacks:enter:buttons:cancel
292
+ Options are passed to the `Scenes` constructor and stored internally.
285
293
 
286
- Injects a cancel button into all `enter` replies.
294
+ ```js
295
+ const scenes = new Scenes(scenes, options, DEV);
296
+ ```
297
+
298
+ ### action:enter:redefinition:reply
299
+
300
+ Controls whether `ctx.reply` is temporarily patched during `enter`.
287
301
 
302
+ When enabled, all `ctx.reply` calls inside `enter`:
303
+
304
+ * preserve existing inline keyboards
305
+ * automatically append the cancel button (if enabled)
306
+
307
+ ```js
308
+ {
309
+ ["action:enter:redefinition:reply"]: {
310
+ enabled: true,
311
+ },
312
+ }
288
313
  ```
314
+
315
+ Disable this option if you want full manual control over `ctx.reply` behavior inside `enter`.
316
+
317
+ ### inline_keyboard:scene:cancel
318
+
319
+ Automatically injects a cancel button into **all `enter` replies**.
320
+
321
+ ```js
289
322
  {
290
- "callbacks:enter:buttons:cancel": {
323
+ ["inline_keyboard:scene:cancel"]: {
291
324
  enabled: true,
325
+
292
326
  component: {
293
327
  text: "Cancel",
294
328
  callback_data: "scene:cancel",
@@ -299,21 +333,56 @@ Injects a cancel button into all `enter` replies.
299
333
 
300
334
  Notes:
301
335
 
302
- * Only affects `enter`
303
- * Cancellation logic is user-defined
336
+ * Only affects `enter(ctx)` replies
337
+ * Does not implement cancellation logic
338
+ * The handler for `scene:cancel` is user-defined
339
+
340
+ ---
341
+
342
+ ## Accessing Options at Runtime
343
+
344
+ Options passed to `Scenes` are stored internally and can be accessed via `scenes.option(name)`.
345
+
346
+ This is useful when you need to reuse option configuration (for example, the cancel button component) outside of the scene lifecycle.
347
+
348
+ Example:
349
+
350
+ ```js
351
+ const cancel = scenes.option("inline_keyboard:scene:cancel");
352
+
353
+ if (cancel?.enabled) {
354
+ console.log(cancel.component);
355
+ // { text: 'Cancel', callback_data: 'scene:cancel' }
356
+ };
357
+ ```
358
+
359
+ Notes:
360
+
361
+ * Returns `null` if the option is not defined
362
+ * The returned object is not cloned; treat it as read-only
304
363
 
305
364
  ---
306
365
 
307
366
  ## API
308
367
 
309
- ### new Scenes(scenes, options)
368
+ ### new Scenes(scenes, options, DEV?)
310
369
 
311
- Creates a scene manager.
370
+ Creates a scene manager backed by `ScenesMiddleware`.
312
371
 
313
372
  Exports:
314
373
 
315
374
  * `scenes.middleware`
316
375
  * `scenes.enter(ctx, scene)`
376
+ * `scenes.option(name)`
377
+
378
+ ### DEV mode
379
+
380
+ When `DEV` is set to `true`, the middleware prints warnings to the console if:
381
+
382
+ * a transition points to a missing step
383
+ * there is no next / previous step in a scene
384
+
385
+ This mode is intended for development and debugging only.
317
386
 
318
387
  ---
319
388
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monixlite/grammy-scenes",
3
- "version": "1.2.2",
3
+ "version": "1.4.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,72 @@
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:redefinition:reply"]: {
33
+ enabled: true,
34
+ },
35
+
36
+ ["inline_keyboard:scene:cancel"]: {
27
37
  enabled: true,
28
38
 
29
39
  component: {
30
- text: "Отменить",
40
+ text: "Cancel",
31
41
  callback_data: "scene:cancel",
32
42
  },
33
43
  },
34
44
 
35
45
  ...options,
36
46
  };
47
+
48
+
49
+ this.DEV = DEV;
37
50
  };
38
51
 
39
52
 
40
53
  scene = (scene) => {
41
- return this.scenes.flat().find((x) => (x.scene.title == scene?.title && x.scene.step == scene?.step));
54
+ return (this.map?.[scene?.title]?.steps?.[scene?.step] ?? null);
42
55
  };
43
56
 
44
57
  step = (scene, dir) => {
45
- const list = this.steps[scene?.title]; {
46
- if (!list) return null;
58
+ const entry = this.map?.[scene?.title]; {
59
+ if (!entry) return null;
47
60
  };
48
61
 
49
62
 
50
- const index = list.indexOf(scene?.step); {
63
+ const index = entry.order.indexOf(scene?.step); {
51
64
  if (index == -1) return null;
52
65
  };
53
66
 
54
67
 
55
- if (dir == "next") return (list[(index + 1)] ?? null);
56
- if (dir == "prev") return (list[(index - 1)] ?? null);
68
+ if (dir == "next") return (entry.order[(index + 1)] ?? null);
69
+ if (dir == "prev") return (entry.order[(index - 1)] ?? null);
57
70
 
58
71
 
59
72
  return null;
@@ -65,31 +78,42 @@ class ScenesMiddleware {
65
78
 
66
79
 
67
80
  const current = this.scene(scene); {
68
- if (!current?.callbacks?.enter) return;
81
+ if (!current?.action?.enter) return;
69
82
  };
70
83
 
71
84
 
72
- const original = ctx.reply.bind(ctx); {
73
- 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
+ if (!this.options?.["action:enter:redefinition:reply"]?.enabled) return await current.action.enter(ctx);
86
+
87
+
88
+ const original = {
89
+ reply: ((typeof (ctx.reply) == "function") ? ctx.reply.bind(ctx) : null),
90
+ };
91
+
92
+ try {
93
+ if (original?.reply) ctx.reply = (text, extra = {}) => {
94
+ const keyboard = [
95
+ ...(extra.reply_markup?.inline_keyboard || []),
96
+ ]; {
97
+ if (this.options?.["inline_keyboard:scene:cancel"]?.enabled) keyboard.push([
98
+ this.options?.["inline_keyboard:scene:cancel"]?.component,
77
99
  ]);
78
100
  };
79
101
 
80
102
 
81
- return original(text, {
103
+ return original.reply(text, {
82
104
  ...extra,
83
105
  reply_markup: {
106
+ ...(extra?.reply_markup || {}),
84
107
  inline_keyboard: keyboard,
85
108
  },
86
109
  });
87
110
  };
88
- };
89
111
 
90
112
 
91
- await current.callbacks.enter(ctx);
92
- ctx.reply = original;
113
+ await current.action.enter(ctx);
114
+ } finally {
115
+ if (original?.reply) ctx.reply = original.reply;
116
+ };
93
117
  };
94
118
 
95
119
  handle = async (ctx, scene, result) => {
@@ -103,7 +127,17 @@ class ScenesMiddleware {
103
127
  const step = this.step({
104
128
  title: scene.title,
105
129
  step: scene.step,
106
- }, "prev");
130
+ }, "prev"); {
131
+ if (!step) {
132
+ if (this.DEV) {
133
+ console.warn(`[grammy-scenes] No previous step found for scene "${scene.title}" (current step: "${scene.step}")`);
134
+ };
135
+
136
+
137
+ ctx.session.scene = null;
138
+ return;
139
+ };
140
+ };
107
141
 
108
142
 
109
143
  return await this.enter(ctx, {
@@ -116,7 +150,17 @@ class ScenesMiddleware {
116
150
  const step = this.step({
117
151
  title: scene.title,
118
152
  step: scene.step,
119
- }, "next");
153
+ }, "next"); {
154
+ if (!step) {
155
+ if (this.DEV) {
156
+ console.warn(`[grammy-scenes] No next step found for scene "${scene.title}" (current step: "${scene.step}")`);
157
+ };
158
+
159
+
160
+ ctx.session.scene = null;
161
+ return;
162
+ };
163
+ };
120
164
 
121
165
 
122
166
  return await this.enter(ctx, {
@@ -139,6 +183,11 @@ class ScenesMiddleware {
139
183
 
140
184
  const transition = String(result).match(/^\^(.*)$/); {
141
185
  if (!transition || !transition?.[1]) {
186
+ if (this.DEV) {
187
+ console.warn(`[grammy-scenes] Invalid transition result:`, result);
188
+ };
189
+
190
+
142
191
  ctx.session.scene = null;
143
192
  return;
144
193
  };
@@ -167,56 +216,74 @@ class ScenesMiddleware {
167
216
 
168
217
 
169
218
  const current = this.scene(scene); {
170
- if (!current?.callbacks) return next();
219
+ if (!current?.action) return next();
171
220
  };
172
221
 
173
222
 
174
- if (ctx?.update && current.callbacks?.update) {
175
- const result = await current.callbacks.update(ctx);
223
+ if (ctx?.update && current.action?.update) {
224
+ const result = await current.action.update(ctx);
176
225
 
177
226
  return await this.handle(ctx, scene, result);
178
227
  };
179
228
 
180
229
 
181
- if (ctx?.message && current.callbacks?.message) {
230
+ if (ctx?.message && current.action?.message) {
182
231
  if (ctx?.message?.caption) ctx.message.text ??= ctx.message.caption;
183
232
 
184
233
 
185
- const result = await current.callbacks.message(ctx);
234
+ const result = await current.action.message(ctx);
186
235
 
187
236
  return await this.handle(ctx, scene, result);
188
237
  };
189
238
 
190
239
 
191
- if (ctx?.callbackQuery && current.callbacks?.query) {
240
+ if (ctx?.callbackQuery && current.action?.callbacks) {
241
+ if (!Array.isArray(current.action.callbacks)) return next();
242
+
243
+
192
244
  const data = ctx.callbackQuery.data;
193
245
 
194
246
 
195
- for (const q of current.callbacks.query) {
196
- if (q.match.test(data)) {
197
- const result = await q.handler(ctx);
247
+ for (const q of current.action.callbacks) {
248
+ if (!(q?.match instanceof RegExp) || !q?.handler) continue;
198
249
 
199
- return await this.handle(ctx, scene, result);
250
+
251
+ const match = data.match(q.match); {
252
+ if (!match) continue;
200
253
  };
254
+
255
+
256
+ const result = await q.handler(ctx, match);
257
+
258
+ return await this.handle(ctx, scene, result);
201
259
  };
202
260
  };
203
261
 
204
262
 
205
263
  return next();
206
264
  };
265
+
266
+
267
+ option = (option) => {
268
+ return (this.options?.[option] ?? null);
269
+ };
207
270
  };
208
271
 
209
272
 
210
273
  class Scenes {
211
- constructor(scenes, options = {}) {
274
+ constructor(scenes = [], options = {}, DEV = false) {
212
275
  this.instance = new ScenesMiddleware({
213
276
  scenes: scenes,
214
277
  options: options,
278
+ DEV: DEV,
215
279
  });
216
280
 
217
281
 
218
- this.middleware = this.instance.middleware;
219
282
  this.enter = this.instance.enter.bind(this.instance);
283
+ this.middleware = this.instance.middleware.bind(this.instance);
284
+
285
+
286
+ this.option = this.instance.option.bind(this.instance);
220
287
  };
221
288
  };
222
289