@monixlite/grammy-scenes 1.3.0 → 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 +104 -32
  2. package/package.json +1 -1
  3. package/src/index.js +38 -13
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # @monixlite/grammy-scenes
2
2
 
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.
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.
4
6
 
5
7
  ---
6
8
 
@@ -14,20 +16,26 @@ npm install @monixlite/grammy-scenes
14
16
 
15
17
  ## Core Concepts
16
18
 
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.
19
+ ### Scene
20
+
21
+ A scene is a logical flow (for example: `auth`, `profile_edit`, `checkout`).
22
+
23
+ ### Step
18
24
 
19
- The active scene state is stored in `ctx.session.scene`.
25
+ A step is a single state inside a scene. Steps are ordered in the same order they are declared.
20
26
 
21
- Each step definition contains:
27
+ ### FSM
22
28
 
23
- * scene identifier (title + step)
24
- * callbacks for lifecycle and update handling
29
+ Internally, the library builds a finite state machine:
30
+
31
+ * scene title → ordered step list
32
+ * step → action handlers
25
33
 
26
34
  ---
27
35
 
28
36
  ## Basic Usage
29
37
 
30
- ```
38
+ ```js
31
39
  const { Bot, session } = require("grammy");
32
40
  const { Scenes } = require("@monixlite/grammy-scenes");
33
41
 
@@ -95,26 +103,26 @@ const scenes = new Scenes([
95
103
 
96
104
  callbacks: [
97
105
  {
98
- match: /^confirm:yes$/,
99
- handler: async (ctx) => {
100
- const { phone } = ctx.session;
106
+ match: /^confirm:(yes|no)$/,
107
+ handler: async (ctx, match) => {
108
+ await ctx.answerCallbackQuery();
101
109
 
102
110
 
103
- await ctx.answerCallbackQuery();
104
- await ctx.reply(`• Thank you! ${phone} saved.`);
111
+ switch (match[1]) {
112
+ case "yes": {
113
+ const { phone } = ctx.session;
105
114
 
106
115
 
107
- return "stop";
108
- },
109
- },
116
+ await ctx.reply(`• Thank you! Your phone number ${phone} has been saved.`);
110
117
 
111
- {
112
- match: /^confirm:no$/,
113
- handler: async (ctx) => {
114
- await ctx.answerCallbackQuery();
115
118
 
119
+ return "stop";
120
+ };
116
121
 
117
- return "^phone";
122
+ default: {
123
+ return "^phone";
124
+ };
125
+ };
118
126
  },
119
127
  },
120
128
  ],
@@ -122,7 +130,7 @@ const scenes = new Scenes([
122
130
  },
123
131
  ],
124
132
  ], {
125
- ["action:enter:extra:reply_markup:inline_keyboard:cancel"]: {
133
+ ["inline_keyboard:scene:cancel"]: {
126
134
  enabled: true,
127
135
 
128
136
  component: {
@@ -146,7 +154,7 @@ bot.callbackQuery("scene:cancel", async (ctx) => {
146
154
 
147
155
 
148
156
  bot.command("start", async (ctx) => {
149
- await scenes.enter(ctx, {
157
+ return await scenes.enter(ctx, {
150
158
  title: "auth",
151
159
  step: "phone",
152
160
  });
@@ -162,7 +170,7 @@ bot.start();
162
170
 
163
171
  ## Scene Definition
164
172
 
165
- ```
173
+ ```js
166
174
  {
167
175
  scene: {
168
176
  title: string,
@@ -171,11 +179,15 @@ bot.start();
171
179
 
172
180
  action: {
173
181
  enter?: (ctx) => Promise<void>,
182
+
174
183
  update?: (ctx) => Promise<SceneResult | void>,
175
184
  message?: (ctx) => Promise<SceneResult | void>,
176
185
  callbacks?: Array<{
177
186
  match: RegExp,
178
- handler: (ctx) => Promise<SceneResult | void>,
187
+ handler: (
188
+ ctx,
189
+ match: RegExpMatchArray,
190
+ ) => Promise<SceneResult | void>,
179
191
  }>,
180
192
  },
181
193
  }
@@ -228,7 +240,7 @@ Behavior:
228
240
 
229
241
  ### Manual Termination
230
242
 
231
- ```
243
+ ```js
232
244
  ctx.session.scene = null;
233
245
  ```
234
246
 
@@ -236,7 +248,7 @@ ctx.session.scene = null;
236
248
 
237
249
  ## Middleware
238
250
 
239
- ```
251
+ ```js
240
252
  bot.use(session(...));
241
253
  bot.use(scenes.middleware);
242
254
  ```
@@ -269,7 +281,7 @@ Transitions are driven by callback return values.
269
281
 
270
282
  Scene termination sets:
271
283
 
272
- ```
284
+ ```js
273
285
  ctx.session.scene = null;
274
286
  ```
275
287
 
@@ -277,13 +289,38 @@ ctx.session.scene = null;
277
289
 
278
290
  ## Options
279
291
 
280
- ### action:enter:extra:reply_markup:inline_keyboard:cancel
292
+ Options are passed to the `Scenes` constructor and stored internally.
293
+
294
+ ```js
295
+ const scenes = new Scenes(scenes, options, DEV);
296
+ ```
281
297
 
282
- Injects a cancel button into all `enter` replies.
298
+ ### action:enter:redefinition:reply
283
299
 
300
+ Controls whether `ctx.reply` is temporarily patched during `enter`.
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
+ }
284
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
285
322
  {
286
- ["action:enter:extra:reply_markup:inline_keyboard:cancel"]: {
323
+ ["inline_keyboard:scene:cancel"]: {
287
324
  enabled: true,
288
325
 
289
326
  component: {
@@ -296,8 +333,33 @@ Injects a cancel button into all `enter` replies.
296
333
 
297
334
  Notes:
298
335
 
299
- * Only affects `enter`
300
- * 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
301
363
 
302
364
  ---
303
365
 
@@ -311,6 +373,16 @@ Exports:
311
373
 
312
374
  * `scenes.middleware`
313
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.
314
386
 
315
387
  ---
316
388
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monixlite/grammy-scenes",
3
- "version": "1.3.0",
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
@@ -29,7 +29,11 @@ class ScenesMiddleware {
29
29
 
30
30
 
31
31
  this.options = {
32
- ["action:enter:extra:reply_markup:inline_keyboard:cancel"]: {
32
+ ["action:enter:redefinition:reply"]: {
33
+ enabled: true,
34
+ },
35
+
36
+ ["inline_keyboard:scene:cancel"]: {
33
37
  enabled: true,
34
38
 
35
39
  component: {
@@ -78,20 +82,25 @@ class ScenesMiddleware {
78
82
  };
79
83
 
80
84
 
81
- const original = ctx.reply.bind(ctx);
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
+ };
82
91
 
83
92
  try {
84
- ctx.reply = (text, extra = {}) => {
93
+ if (original?.reply) ctx.reply = (text, extra = {}) => {
85
94
  const keyboard = [
86
95
  ...(extra.reply_markup?.inline_keyboard || []),
87
96
  ]; {
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,
97
+ if (this.options?.["inline_keyboard:scene:cancel"]?.enabled) keyboard.push([
98
+ this.options?.["inline_keyboard:scene:cancel"]?.component,
90
99
  ]);
91
100
  };
92
101
 
93
102
 
94
- return original(text, {
103
+ return original.reply(text, {
95
104
  ...extra,
96
105
  reply_markup: {
97
106
  ...(extra?.reply_markup || {}),
@@ -103,7 +112,7 @@ class ScenesMiddleware {
103
112
 
104
113
  await current.action.enter(ctx);
105
114
  } finally {
106
- ctx.reply = original;
115
+ if (original?.reply) ctx.reply = original.reply;
107
116
  };
108
117
  };
109
118
 
@@ -174,6 +183,11 @@ class ScenesMiddleware {
174
183
 
175
184
  const transition = String(result).match(/^\^(.*)$/); {
176
185
  if (!transition || !transition?.[1]) {
186
+ if (this.DEV) {
187
+ console.warn(`[grammy-scenes] Invalid transition result:`, result);
188
+ };
189
+
190
+
177
191
  ctx.session.scene = null;
178
192
  return;
179
193
  };
@@ -231,20 +245,28 @@ class ScenesMiddleware {
231
245
 
232
246
 
233
247
  for (const q of current.action.callbacks) {
234
- if (!q?.match?.test || !q?.handler) continue;
235
-
248
+ if (!(q?.match instanceof RegExp) || !q?.handler) continue;
236
249
 
237
- if (q.match.test(data)) {
238
- const result = await q.handler(ctx);
239
250
 
240
- return await this.handle(ctx, scene, result);
251
+ const match = data.match(q.match); {
252
+ if (!match) continue;
241
253
  };
254
+
255
+
256
+ const result = await q.handler(ctx, match);
257
+
258
+ return await this.handle(ctx, scene, result);
242
259
  };
243
260
  };
244
261
 
245
262
 
246
263
  return next();
247
264
  };
265
+
266
+
267
+ option = (option) => {
268
+ return (this.options?.[option] ?? null);
269
+ };
248
270
  };
249
271
 
250
272
 
@@ -257,8 +279,11 @@ class Scenes {
257
279
  });
258
280
 
259
281
 
260
- this.middleware = this.instance.middleware.bind(this.instance);
261
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);
262
287
  };
263
288
  };
264
289