@monixlite/grammy-scenes 1.0.0 → 1.2.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 +297 -23
  2. package/package.json +2 -2
  3. package/src/index.js +40 -14
package/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # @monixlite/grammy-scenes
2
2
 
3
- Scene middleware for grammY with step-based navigation.
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.
5
+
6
+ ---
4
7
 
5
8
  ## Installation
6
9
 
@@ -10,8 +13,22 @@ npm install @monixlite/grammy-scenes
10
13
 
11
14
  ## Requirements
12
15
 
13
- * Node.js >= 16
14
- * grammY >= 1.19
16
+ Node.js >= 16
17
+ grammY >= 1.19
18
+
19
+ ---
20
+
21
+ ## Core Concept
22
+
23
+ A scene is a sequence of steps united under a common title.
24
+ Each step is described by an object containing:
25
+
26
+ • scene and step identifiers
27
+ • a set of callbacks reacting to Telegram events (enter, message, callbackQuery)
28
+
29
+ The current scene state is stored in `ctx.session.scene`.
30
+
31
+ ---
15
32
 
16
33
  ## Basic Usage
17
34
 
@@ -20,13 +37,10 @@ const { Bot, session } = require("grammy");
20
37
  const { Scenes } = require("@monixlite/grammy-scenes");
21
38
 
22
39
 
23
- const bot = new Bot(""); {
40
+ const bot = new Bot("..."); {
24
41
  bot.use(session({
25
42
  initial: () => ({
26
43
  scene: null,
27
- step: null,
28
- name: null,
29
- age: null,
30
44
  }),
31
45
  }));
32
46
  };
@@ -42,22 +56,100 @@ const scene = new Scenes([
42
56
 
43
57
  callbacks: {
44
58
  enter: async (ctx) => {
45
- await ctx.reply("Введите номер:");
59
+ await ctx.reply(" Enter your phone number:");
46
60
  },
47
61
 
48
62
  message: async (ctx) => {
49
- await ctx.reply(`Номер: ${ctx.message.text.trim()}`);
63
+ ctx.session.phone = ctx.message.text.trim();
64
+ },
65
+ },
66
+ },
67
+
68
+ {
69
+ scene: {
70
+ title: "auth",
71
+ step: "confirm",
72
+ },
50
73
 
51
- return "stop";
74
+ callbacks: {
75
+ enter: async (ctx) => {
76
+ const { phone } = ctx.session;
77
+
78
+
79
+ await ctx.reply(([
80
+ `• Your phone number: ${phone}`,
81
+ `• Is this correct?`
82
+ ]).join("\n"), {
83
+ reply_markup: {
84
+ inline_keyboard: [
85
+ [
86
+ {
87
+ text: "• Yes",
88
+ callback_data: "confirm:yes"
89
+ },
90
+
91
+ {
92
+ text: "• No",
93
+ callback_data: "confirm:no"
94
+ },
95
+ ],
96
+ ],
97
+ },
98
+ });
52
99
  },
100
+
101
+ query: [
102
+ {
103
+ match: /^confirm:yes$/,
104
+ handler: async (ctx) => {
105
+ const { phone } = ctx.session;
106
+
107
+
108
+ await ctx.answerCallbackQuery();
109
+ await ctx.reply(`• Thank you! Your phone number ${phone} has been saved.`);
110
+
111
+
112
+ return "stop";
113
+ },
114
+ },
115
+ {
116
+ match: /^confirm:no$/,
117
+ handler: async (ctx) => {
118
+ await ctx.answerCallbackQuery();
119
+
120
+
121
+ return "^phone";
122
+ },
123
+ },
124
+ ],
53
125
  },
54
126
  },
55
127
  ],
56
- ]);
128
+ ], {
129
+ ["callbacks:enter:buttons:cancel"]: {
130
+ enabled: true,
131
+
132
+ component: {
133
+ text: "• Cancel scene •",
134
+ callback_data: "scene:cancel",
135
+ },
136
+ },
137
+ });
138
+
139
+
140
+ bot.callbackQuery("scene:cancel", async (ctx) => {
141
+ ctx.session.scene = null;
142
+
143
+
144
+ await ctx.editMessageReplyMarkup(null);
145
+ await ctx.answerCallbackQuery();
146
+
147
+ await ctx.reply("• Scene cancelled!");
148
+ });
57
149
 
58
150
 
59
151
  bot.command("start", async (ctx) => {
60
- await scene.enter(ctx, {
152
+ return await scene.enter(ctx, {
61
153
  title: "auth",
62
154
  step: "phone",
63
155
  });
@@ -69,9 +161,11 @@ bot.use(scene.middleware);
69
161
  bot.start();
70
162
  ```
71
163
 
164
+ ---
165
+
72
166
  ## Scene Definition
73
167
 
74
- Каждый шаг сцены описывается объектом:
168
+ Each scene step is described by the following structure:
75
169
 
76
170
  ```js
77
171
  {
@@ -82,23 +176,77 @@ bot.start();
82
176
 
83
177
  callbacks: {
84
178
  enter?: (ctx) => Promise<void>,
85
- message?: (ctx) => Promise<"next" | "stop" | void>,
179
+ update?: (ctx) => Promise<SceneResult | void>,
180
+ message?: (ctx) => Promise<SceneResult | void>,
181
+ query?: Array<{
182
+ match: RegExp,
183
+ handler: (ctx) => Promise<SceneResult | void>,
184
+ }>,
86
185
  },
87
186
  }
88
187
  ```
89
188
 
90
- ### callbacks.enter
189
+ `SceneResult` is a value that determines the next transition (described below).
190
+
191
+ ---
192
+
193
+ ## Callbacks
194
+
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)
209
+
210
+ Triggered on **any update** while the step is active.
211
+
212
+ Details:
91
213
 
92
- Вызывается при входе в шаг сцены.
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
93
218
 
94
- ### callbacks.message
219
+ ---
95
220
 
96
- Вызывается при получении сообщения пользователя.
97
- Возврат `"stop"` завершает сцену.
221
+ ### callbacks.message(ctx)
222
+
223
+ Triggered when a text message is received while the step is active.
224
+
225
+ ---
226
+
227
+ ### callbacks.query
228
+
229
+ An array of `callbackQuery` handlers.
230
+
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`.
242
+
243
+ ---
98
244
 
99
245
  ## Scene Control
100
246
 
101
- ### Enter scene
247
+ ### scenes.enter(ctx, scene)
248
+
249
+ Forces entry into a scene and step.
102
250
 
103
251
  ```js
104
252
  await scenes.enter(ctx, {
@@ -107,21 +255,147 @@ await scenes.enter(ctx, {
107
255
  });
108
256
  ```
109
257
 
110
- ### Cancel scene manually
258
+ Behavior:
259
+
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
+ ---
266
+
267
+ ### Cancel a Scene Manually
268
+
269
+ A scene can be terminated manually:
111
270
 
112
271
  ```js
113
272
  ctx.session.scene = null;
114
273
  ```
115
274
 
116
- ## Middleware
275
+ After this, the middleware stops processing scene events.
276
+
277
+ ---
117
278
 
118
- Middleware должен быть подключён **после** session middleware:
279
+ ## Middleware
119
280
 
120
281
  ```js
121
282
  bot.use(session(...));
122
283
  bot.use(scenes.middleware);
123
284
  ```
124
285
 
286
+ The middleware:
287
+
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
292
+
293
+ The middleware **must** be registered after the session middleware.
294
+
295
+ ---
296
+
297
+ ## Step Navigation
298
+
299
+ Transitions are determined by the value returned from callbacks.
300
+
301
+ ### Terminating a Scene
302
+
303
+ • `"stop"`, `"exit"`, `"!"`
304
+ → `ctx.session.scene = null`
305
+
306
+ ---
307
+
308
+ ### All Transitions
309
+
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
+ ---
320
+
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
+ ```
340
+
341
+ ```js
342
+ return {
343
+ scene: {
344
+ title: "auth",
345
+ step: "phone",
346
+ },
347
+ };
348
+ ```
349
+
350
+ ---
351
+
352
+ ## Options
353
+
354
+ Options are passed as the second argument to `new Scenes()`.
355
+
356
+ ### callbacks:enter:buttons:cancel
357
+
358
+ Adds a cancel button to all messages sent inside `callbacks.enter`.
359
+
360
+ ```js
361
+ {
362
+ "callbacks:enter:buttons:cancel": {
363
+ enabled: true,
364
+
365
+ component: {
366
+ text: "Cancel",
367
+ callback_data: "scene:cancel",
368
+ },
369
+ },
370
+ }
371
+ ```
372
+
373
+ Behavior:
374
+
375
+ • Works only inside `callbacks.enter`
376
+ • The button is appended automatically
377
+ • Scene cancellation logic must be implemented by the user
378
+
379
+ ---
380
+
381
+ ## Scenes Class
382
+
383
+ ### new Scenes(scenes, options)
384
+
385
+ Creates a scene manager instance.
386
+
387
+ Parameters:
388
+
389
+ • `scenes` — an array of grouped scene steps
390
+ • `options` — configuration object
391
+
392
+ Exports:
393
+
394
+ • `scenes.middleware` — grammY middleware
395
+ • `scenes.enter(ctx, scene)` — method to enter a scene
396
+
397
+ ---
398
+
125
399
  ## License
126
400
 
127
401
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monixlite/grammy-scenes",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Scene middleware for grammY with step-based navigation",
5
5
  "main": "src/index.js",
6
6
  "type": "commonjs",
@@ -17,4 +17,4 @@
17
17
  "peerDependencies": {
18
18
  "grammy": "^1.0.0"
19
19
  }
20
- }
20
+ }
package/src/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  class ScenesMiddleware {
2
- constructor({ scenes }) {
2
+ constructor({
3
+ scenes,
4
+ options = {},
5
+ }) {
3
6
  this.scenes = scenes;
4
7
 
5
-
6
8
  this.steps = {}; {
7
9
  for (const group of scenes) {
8
10
  for (const item of group) {
@@ -18,6 +20,20 @@ class ScenesMiddleware {
18
20
  };
19
21
  };
20
22
  };
23
+
24
+
25
+ this.options = {
26
+ ["callbacks:enter:buttons:cancel"]: {
27
+ enabled: true,
28
+
29
+ component: {
30
+ text: "Отменить",
31
+ callback_data: "scene:cancel",
32
+ },
33
+ },
34
+
35
+ ...options,
36
+ };
21
37
  };
22
38
 
23
39
 
@@ -55,18 +71,17 @@ class ScenesMiddleware {
55
71
 
56
72
  const original = ctx.reply.bind(ctx); {
57
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,
77
+ ]);
78
+ };
79
+
80
+
58
81
  return original(text, {
59
82
  ...extra,
60
83
  reply_markup: {
61
- inline_keyboard: [
62
- ...(extra.reply_markup?.inline_keyboard || []),
63
- [
64
- {
65
- text: "Отменить",
66
- callback_data: "scene:cancel",
67
- },
68
- ],
69
- ],
84
+ inline_keyboard: keyboard,
70
85
  },
71
86
  });
72
87
  };
@@ -156,14 +171,24 @@ class ScenesMiddleware {
156
171
  };
157
172
 
158
173
 
159
- if (ctx.message && current.callbacks.message) {
174
+ if (ctx?.update && current.callbacks?.update) {
175
+ const result = await current.callbacks.update(ctx);
176
+
177
+ return await this.handle(ctx, scene, result);
178
+ };
179
+
180
+
181
+ if (ctx?.message && current.callbacks?.message) {
182
+ if (ctx?.message?.caption) ctx.message.text ??= ctx.message.caption;
183
+
184
+
160
185
  const result = await current.callbacks.message(ctx);
161
186
 
162
187
  return await this.handle(ctx, scene, result);
163
188
  };
164
189
 
165
190
 
166
- if (ctx.callbackQuery && current.callbacks.query) {
191
+ if (ctx?.callbackQuery && current.callbacks?.query) {
167
192
  const data = ctx.callbackQuery.data;
168
193
 
169
194
 
@@ -183,9 +208,10 @@ class ScenesMiddleware {
183
208
 
184
209
 
185
210
  class Scenes {
186
- constructor(scenes) {
211
+ constructor(scenes, options = {}) {
187
212
  this.instance = new ScenesMiddleware({
188
213
  scenes: scenes,
214
+ options: options,
189
215
  });
190
216
 
191
217