@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.
- package/README.md +104 -32
- package/package.json +1 -1
- package/src/index.js +38 -13
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @monixlite/grammy-scenes
|
|
2
2
|
|
|
3
|
-
A lightweight
|
|
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
|
-
|
|
19
|
+
### Scene
|
|
20
|
+
|
|
21
|
+
A scene is a logical flow (for example: `auth`, `profile_edit`, `checkout`).
|
|
22
|
+
|
|
23
|
+
### Step
|
|
18
24
|
|
|
19
|
-
|
|
25
|
+
A step is a single state inside a scene. Steps are ordered in the same order they are declared.
|
|
20
26
|
|
|
21
|
-
|
|
27
|
+
### FSM
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
106
|
+
match: /^confirm:(yes|no)$/,
|
|
107
|
+
handler: async (ctx, match) => {
|
|
108
|
+
await ctx.answerCallbackQuery();
|
|
101
109
|
|
|
102
110
|
|
|
103
|
-
|
|
104
|
-
|
|
111
|
+
switch (match[1]) {
|
|
112
|
+
case "yes": {
|
|
113
|
+
const { phone } = ctx.session;
|
|
105
114
|
|
|
106
115
|
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
["
|
|
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: (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
["
|
|
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
|
-
*
|
|
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
package/src/index.js
CHANGED
|
@@ -29,7 +29,11 @@ class ScenesMiddleware {
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
this.options = {
|
|
32
|
-
["action:enter:
|
|
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
|
-
|
|
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?.["
|
|
89
|
-
this.options?.["
|
|
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
|
|
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
|
-
|
|
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
|
|