@rilong/grammyjs-conversations-esm 2.0.2
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/LICENSE +21 -0
- package/README.md +59 -0
- package/out/conversation.d.ts +885 -0
- package/out/conversation.js +832 -0
- package/out/deps.node.d.ts +2 -0
- package/out/deps.node.js +1 -0
- package/out/engine.d.ts +212 -0
- package/out/engine.js +238 -0
- package/out/form.d.ts +530 -0
- package/out/form.js +598 -0
- package/out/menu.d.ts +593 -0
- package/out/menu.js +698 -0
- package/out/mod.d.ts +8 -0
- package/out/mod.js +8 -0
- package/out/nope.d.ts +16 -0
- package/out/nope.js +35 -0
- package/out/plugin.d.ts +678 -0
- package/out/plugin.js +578 -0
- package/out/resolve.d.ts +43 -0
- package/out/resolve.js +21 -0
- package/out/state.d.ts +147 -0
- package/out/state.js +125 -0
- package/out/storage.d.ts +169 -0
- package/out/storage.js +105 -0
- package/package.json +43 -0
package/out/menu.js
ADDED
@@ -0,0 +1,698 @@
|
|
1
|
+
var _a;
|
2
|
+
import { Composer, } from "./deps.node.js";
|
3
|
+
import { youTouchYouDie } from "./nope.js";
|
4
|
+
const b = 0xff; // mask for lowest byte
|
5
|
+
const toNums = (str) => Array.from(str).map((c) => c.codePointAt(0));
|
6
|
+
const dec = new TextDecoder();
|
7
|
+
/** Efficiently computes a 4-byte hash of an int32 array */
|
8
|
+
function tinyHash(nums) {
|
9
|
+
// Same hash as the menu plugin uses
|
10
|
+
let hash = 17;
|
11
|
+
for (const n of nums)
|
12
|
+
hash = ((hash << 5) + (hash << 2) + hash + n) >>> 0; // hash = 37 * hash + n
|
13
|
+
const bytes = [hash >>> 24, (hash >> 16) & b, (hash >> 8) & b, hash & b];
|
14
|
+
return dec.decode(Uint8Array.from(bytes)); // turn bytes into string
|
15
|
+
}
|
16
|
+
const ops = Symbol("conversation menu building operations");
|
17
|
+
const opts = Symbol("conversation menu building options");
|
18
|
+
const INJECT_METHODS = new Set([
|
19
|
+
"editMessageText",
|
20
|
+
"editMessageCaption",
|
21
|
+
"editMessageMedia",
|
22
|
+
"editMessageReplyMarkup",
|
23
|
+
"stopPoll",
|
24
|
+
]);
|
25
|
+
/**
|
26
|
+
* A container for many menu instances that are created during a replay of a
|
27
|
+
* conversation.
|
28
|
+
*
|
29
|
+
* You typically do not have to construct this class yourself, but it is used
|
30
|
+
* internally in order to provide `conversation.menu` inside conversations.
|
31
|
+
*/
|
32
|
+
export class ConversationMenuPool {
|
33
|
+
constructor() {
|
34
|
+
this.index = new Map();
|
35
|
+
this.dirty = new Map();
|
36
|
+
}
|
37
|
+
/**
|
38
|
+
* Marks a menu as dirty. When an API call will be performed that edits the
|
39
|
+
* specified message, the given menu will be injected into the payload. If
|
40
|
+
* no such API happens while processing an update, the all dirty menus will
|
41
|
+
* be updated eagerly using `editMessageReplyMarkup`.
|
42
|
+
*
|
43
|
+
* @param chat_id The chat identifier of the menu
|
44
|
+
* @param message_id The message identifier of the menu
|
45
|
+
* @param menu The menu to inject into a payload
|
46
|
+
*/
|
47
|
+
markMenuAsDirty(chat_id, message_id, menu) {
|
48
|
+
let chat = this.dirty.get(chat_id);
|
49
|
+
if (chat === undefined) {
|
50
|
+
chat = new Map();
|
51
|
+
this.dirty.set(chat_id, chat);
|
52
|
+
}
|
53
|
+
chat.set(message_id, { menu });
|
54
|
+
}
|
55
|
+
/**
|
56
|
+
* Looks up a dirty menu, returns it, and marks it as clean. Returns
|
57
|
+
* undefined if the given message does not have a menu that is marked as
|
58
|
+
* dirty.
|
59
|
+
*
|
60
|
+
* @param chat_id The chat identifier of the menu
|
61
|
+
* @param message_id The message identifier of the menu
|
62
|
+
*/
|
63
|
+
getAndClearDirtyMenu(chat_id, message_id) {
|
64
|
+
const chat = this.dirty.get(chat_id);
|
65
|
+
if (chat === undefined)
|
66
|
+
return undefined;
|
67
|
+
const message = chat.get(message_id);
|
68
|
+
chat.delete(message_id);
|
69
|
+
if (chat.size === 0)
|
70
|
+
this.dirty.delete(chat_id);
|
71
|
+
return message === null || message === void 0 ? void 0 : message.menu;
|
72
|
+
}
|
73
|
+
/**
|
74
|
+
* Creates a new conversational menu with the given identifier and options.
|
75
|
+
*
|
76
|
+
* If no identifier is specified, an identifier will be auto-generated. This
|
77
|
+
* identifier is guaranteed not to clash with any outside menu identifiers
|
78
|
+
* used by [the menu plugin](https://grammy.dev/plugins/menu). In contrast,
|
79
|
+
* if an identifier is passed that coincides with the identifier of a menu
|
80
|
+
* outside the conversation, menu compatibility can be achieved.
|
81
|
+
*
|
82
|
+
* @param id An optional menu identifier
|
83
|
+
* @param options An optional options object
|
84
|
+
*/
|
85
|
+
create(id, options) {
|
86
|
+
if (id === undefined) {
|
87
|
+
id = createId(this.index.size);
|
88
|
+
}
|
89
|
+
else if (id.includes("/")) {
|
90
|
+
throw new Error(`You cannot use '/' in a menu identifier ('${id}')`);
|
91
|
+
}
|
92
|
+
const menu = new ConversationMenu(id, options);
|
93
|
+
this.index.set(id, menu);
|
94
|
+
return menu;
|
95
|
+
}
|
96
|
+
/**
|
97
|
+
* Looks up a menu by its identifier and returns the menu. Throws an error
|
98
|
+
* if the identifier cannot be found.
|
99
|
+
*
|
100
|
+
* @param id The menu identifier to look up
|
101
|
+
*/
|
102
|
+
lookup(id) {
|
103
|
+
const idString = typeof id === "string" ? id : id.id;
|
104
|
+
const menu = this.index.get(idString);
|
105
|
+
if (menu === undefined) {
|
106
|
+
const validIds = Array.from(this.index.keys())
|
107
|
+
.map((k) => `'${k}'`)
|
108
|
+
.join(", ");
|
109
|
+
throw new Error(`Menu '${idString}' is not known! Known menus are: ${validIds}`);
|
110
|
+
}
|
111
|
+
return menu;
|
112
|
+
}
|
113
|
+
/**
|
114
|
+
* Prepares a context object for supporting conversational menus. Returns a
|
115
|
+
* function to handle clicks.
|
116
|
+
*
|
117
|
+
* @param ctx The context object to prepare
|
118
|
+
*/
|
119
|
+
install(ctx) {
|
120
|
+
// === SETUP RENDERING ===
|
121
|
+
/**
|
122
|
+
* Renders a conversational menu to a button array.
|
123
|
+
*
|
124
|
+
* @param id A valid identifier of a conversational menu
|
125
|
+
*/
|
126
|
+
const render = async (id) => {
|
127
|
+
const self = this.index.get(id);
|
128
|
+
if (self === undefined)
|
129
|
+
throw new Error("should never happen");
|
130
|
+
const renderer = createDisplayRenderer(id, ctx);
|
131
|
+
const rendered = await renderer(self[ops]);
|
132
|
+
const fingerprint = await uniform(ctx, self[opts].fingerprint);
|
133
|
+
appendHashes(rendered, fingerprint);
|
134
|
+
return rendered;
|
135
|
+
};
|
136
|
+
/**
|
137
|
+
* Replaces all menu instances by their rendered versions inside the
|
138
|
+
* given payload object.
|
139
|
+
*
|
140
|
+
* @param payload The payload to mutate
|
141
|
+
*/
|
142
|
+
const prepare = async (payload) => {
|
143
|
+
if (payload.reply_markup instanceof ConversationMenu) {
|
144
|
+
const rendered = await render(payload.reply_markup.id);
|
145
|
+
payload.reply_markup = { inline_keyboard: rendered };
|
146
|
+
}
|
147
|
+
};
|
148
|
+
// === HANDLE OUTGOING MENUS ===
|
149
|
+
ctx.api.config.use(
|
150
|
+
// Install a transformer that watches all outgoing payloads for menus
|
151
|
+
async (prev, method, payload, signal) => {
|
152
|
+
const p = payload;
|
153
|
+
if (p !== undefined) {
|
154
|
+
if (Array.isArray(p.results)) {
|
155
|
+
await Promise.all(p.results.map((r) => prepare(r)));
|
156
|
+
}
|
157
|
+
else {
|
158
|
+
await prepare(p);
|
159
|
+
}
|
160
|
+
}
|
161
|
+
return await prev(method, payload, signal);
|
162
|
+
},
|
163
|
+
// Install a transformer that injects dirty menus into API calls
|
164
|
+
async (prev, method, payload, signal) => {
|
165
|
+
if (INJECT_METHODS.has(method) &&
|
166
|
+
!("reply_markup" in payload) &&
|
167
|
+
"chat_id" in payload &&
|
168
|
+
payload.chat_id !== undefined &&
|
169
|
+
"message_id" in payload &&
|
170
|
+
payload.message_id !== undefined) {
|
171
|
+
Object.assign(payload, {
|
172
|
+
reply_markup: this.getAndClearDirtyMenu(payload.chat_id, payload.message_id),
|
173
|
+
});
|
174
|
+
}
|
175
|
+
return await prev(method, payload, signal);
|
176
|
+
});
|
177
|
+
// === CHECK INCOMING UPDATES ===
|
178
|
+
const skip = { handleClicks: () => Promise.resolve({ next: true }) };
|
179
|
+
// Parse callback query data and check if this is for us
|
180
|
+
if (!ctx.has("callback_query:data"))
|
181
|
+
return skip;
|
182
|
+
const data = ctx.callbackQuery.data;
|
183
|
+
const parsed = parseId(data);
|
184
|
+
if (parsed === undefined)
|
185
|
+
return skip;
|
186
|
+
const { id, parts } = parsed;
|
187
|
+
if (parts.length < 4)
|
188
|
+
return skip;
|
189
|
+
const [rowStr, colStr, payload, ...rest] = parts;
|
190
|
+
const [type, ...h] = rest.join("/");
|
191
|
+
const hash = h.join("");
|
192
|
+
// Skip handling if this is not a known format
|
193
|
+
if (!rowStr || !colStr)
|
194
|
+
return skip;
|
195
|
+
if (type !== "h" && type !== "f")
|
196
|
+
return skip;
|
197
|
+
// Get identified menu from index
|
198
|
+
const menu = this.index.get(id);
|
199
|
+
if (menu === undefined)
|
200
|
+
return skip;
|
201
|
+
const row = parseInt(rowStr, 16);
|
202
|
+
const col = parseInt(colStr, 16);
|
203
|
+
if (row < 0 || col < 0) {
|
204
|
+
const msg = `Invalid button position '${rowStr}/${colStr}'`;
|
205
|
+
throw new Error(msg);
|
206
|
+
}
|
207
|
+
// We now know that the update needs to be handled by `menu`.
|
208
|
+
// === HANDLE INCOMING CALLBACK QUERIES ===
|
209
|
+
// Provide payload on `ctx.match` if it is not empty
|
210
|
+
if (payload)
|
211
|
+
ctx.match = payload;
|
212
|
+
const nav = async ({ immediate } = {}, menu) => {
|
213
|
+
const chat = ctx.chatId;
|
214
|
+
if (chat === undefined) {
|
215
|
+
throw new Error("This update does not belong to a chat, so you cannot use this context object to send a menu");
|
216
|
+
}
|
217
|
+
const message = ctx.msgId;
|
218
|
+
if (message === undefined) {
|
219
|
+
throw new Error("This update does not contain a message, so you cannot use this context object to send a menu");
|
220
|
+
}
|
221
|
+
this.markMenuAsDirty(chat, message, menu);
|
222
|
+
if (immediate)
|
223
|
+
await ctx.editMessageReplyMarkup();
|
224
|
+
};
|
225
|
+
return {
|
226
|
+
handleClicks: async () => {
|
227
|
+
const controls = {
|
228
|
+
update: (config) => nav(config, menu),
|
229
|
+
close: (config) => nav(config, undefined),
|
230
|
+
nav: (to, config) => nav(config, this.lookup(to)),
|
231
|
+
back: async (config) => {
|
232
|
+
const p = menu[opts].parent;
|
233
|
+
if (p === undefined) {
|
234
|
+
throw new Error(`Menu ${menu.id} has no parent!`);
|
235
|
+
}
|
236
|
+
await nav(config, this.lookup(p));
|
237
|
+
},
|
238
|
+
};
|
239
|
+
Object.assign(ctx, { menu: controls });
|
240
|
+
// We now have prepared the context for being handled by `menu` so we
|
241
|
+
// can actually begin handling the received callback query.
|
242
|
+
const mctx = ctx;
|
243
|
+
const menuIsOutdated = async () => {
|
244
|
+
console.error(`conversational menu '${id}' was outdated!`);
|
245
|
+
console.error(new Error("trace").stack);
|
246
|
+
await Promise.all([
|
247
|
+
ctx.answerCallbackQuery(),
|
248
|
+
ctx.editMessageReplyMarkup(),
|
249
|
+
]);
|
250
|
+
};
|
251
|
+
// Check fingerprint if used
|
252
|
+
const fingerprint = await uniform(ctx, menu[opts].fingerprint);
|
253
|
+
const useFp = fingerprint !== "";
|
254
|
+
if (useFp !== (type === "f")) {
|
255
|
+
await menuIsOutdated();
|
256
|
+
return { next: false };
|
257
|
+
}
|
258
|
+
if (useFp && tinyHash(toNums(fingerprint)) !== hash) {
|
259
|
+
await menuIsOutdated();
|
260
|
+
return { next: false };
|
261
|
+
}
|
262
|
+
// Create renderer and perform rendering
|
263
|
+
const renderer = createHandlerRenderer(ctx);
|
264
|
+
const range = await renderer(menu[ops]);
|
265
|
+
// Check dimension
|
266
|
+
if (!useFp && (row >= range.length || col >= range[row].length)) {
|
267
|
+
await menuIsOutdated();
|
268
|
+
return { next: false };
|
269
|
+
}
|
270
|
+
// Check correct button type
|
271
|
+
const btn = range[row][col];
|
272
|
+
if (!("middleware" in btn)) {
|
273
|
+
if (!useFp) {
|
274
|
+
await menuIsOutdated();
|
275
|
+
return { next: false };
|
276
|
+
}
|
277
|
+
throw new Error(`Cannot invoke handlers because menu '${id}' is outdated!`);
|
278
|
+
}
|
279
|
+
// Check dimensions
|
280
|
+
if (!useFp) {
|
281
|
+
const rowCount = range.length;
|
282
|
+
const rowLengths = range.map((row) => row.length);
|
283
|
+
const label = await uniform(ctx, btn.text);
|
284
|
+
const data = [rowCount, ...rowLengths, ...toNums(label)];
|
285
|
+
const expectedHash = tinyHash(data);
|
286
|
+
if (hash !== expectedHash) {
|
287
|
+
await menuIsOutdated();
|
288
|
+
return { next: false };
|
289
|
+
}
|
290
|
+
}
|
291
|
+
// Run handler
|
292
|
+
const c = new Composer();
|
293
|
+
if (menu[opts].autoAnswer) {
|
294
|
+
c.fork((ctx) => ctx.answerCallbackQuery());
|
295
|
+
}
|
296
|
+
c.use(...btn.middleware);
|
297
|
+
let next = false;
|
298
|
+
await c.middleware()(mctx, () => {
|
299
|
+
next = true;
|
300
|
+
return Promise.resolve();
|
301
|
+
});
|
302
|
+
// Update all dirty menus
|
303
|
+
const dirtyChats = Array.from(this.dirty.entries());
|
304
|
+
await Promise.all(dirtyChats.flatMap(([chat, messages]) => Array
|
305
|
+
.from(messages.keys())
|
306
|
+
.map((message) => ctx.api.editMessageReplyMarkup(chat, message))));
|
307
|
+
return { next };
|
308
|
+
},
|
309
|
+
};
|
310
|
+
}
|
311
|
+
}
|
312
|
+
/** Generate short and unique identifier that is considered invalid by all other menu instances */
|
313
|
+
function createId(size) {
|
314
|
+
return `//${size.toString(36)}`;
|
315
|
+
}
|
316
|
+
function parseId(data) {
|
317
|
+
if (data.startsWith("//")) {
|
318
|
+
const [id, ...parts] = data.substring(2).split("/");
|
319
|
+
if (!id || isNaN(parseInt(id, 36)))
|
320
|
+
return undefined;
|
321
|
+
return { id: "//" + id, parts };
|
322
|
+
}
|
323
|
+
else {
|
324
|
+
const [id, ...parts] = data.split("/");
|
325
|
+
if (id === undefined)
|
326
|
+
return undefined;
|
327
|
+
return { id, parts };
|
328
|
+
}
|
329
|
+
}
|
330
|
+
/**
|
331
|
+
* A conversational menu range is a two-dimensional array of buttons.
|
332
|
+
*
|
333
|
+
* This array is a part of the total two-dimensional array of buttons. This is
|
334
|
+
* mostly useful if you want to dynamically generate the structure of the
|
335
|
+
* conversational menu on the fly.
|
336
|
+
*/
|
337
|
+
export class ConversationMenuRange {
|
338
|
+
constructor() {
|
339
|
+
this[_a] = [];
|
340
|
+
}
|
341
|
+
/**
|
342
|
+
* This method is used internally whenever a new range is added.
|
343
|
+
*
|
344
|
+
* @param range A range object or a two-dimensional array of menu buttons
|
345
|
+
*/
|
346
|
+
addRange(...range) {
|
347
|
+
this[ops].push(...range);
|
348
|
+
return this;
|
349
|
+
}
|
350
|
+
/**
|
351
|
+
* This method is used internally whenever new buttons are added. Adds the
|
352
|
+
* buttons to the current row.
|
353
|
+
*
|
354
|
+
* @param btns Menu button object
|
355
|
+
*/
|
356
|
+
add(...btns) {
|
357
|
+
return this.addRange([btns]);
|
358
|
+
}
|
359
|
+
/**
|
360
|
+
* Adds a 'line break'. Call this method to make sure that the next added
|
361
|
+
* buttons will be on a new row.
|
362
|
+
*/
|
363
|
+
row() {
|
364
|
+
return this.addRange([[], []]);
|
365
|
+
}
|
366
|
+
/**
|
367
|
+
* Adds a new URL button. Telegram clients will open the provided URL when
|
368
|
+
* the button is pressed. Note that they will not notify your bot when that
|
369
|
+
* happens, so you cannot react to this button.
|
370
|
+
*
|
371
|
+
* @param text The text to display
|
372
|
+
* @param url HTTP or tg:// url to be opened when button is pressed. Links tg://user?id=<user_id> can be used to mention a user by their ID without using a username, if this is allowed by their privacy settings.
|
373
|
+
*/
|
374
|
+
url(text, url) {
|
375
|
+
return this.add({ text, url });
|
376
|
+
}
|
377
|
+
text(text, ...middleware) {
|
378
|
+
return this.add(typeof text === "object"
|
379
|
+
? { ...text, middleware }
|
380
|
+
: { text, middleware });
|
381
|
+
}
|
382
|
+
/**
|
383
|
+
* Adds a new web app button, confer https://core.telegram.org/bots/webapps
|
384
|
+
*
|
385
|
+
* @param text The text to display
|
386
|
+
* @param url An HTTPS URL of a Web App to be opened with additional data
|
387
|
+
*/
|
388
|
+
webApp(text, url) {
|
389
|
+
return this.add({ text, web_app: { url } });
|
390
|
+
}
|
391
|
+
/**
|
392
|
+
* Adds a new login button. This can be used as a replacement for the
|
393
|
+
* Telegram Login Widget. You must specify an HTTPS URL used to
|
394
|
+
* automatically authorize the user.
|
395
|
+
*
|
396
|
+
* @param text The text to display
|
397
|
+
* @param loginUrl The login URL as string or `LoginUrl` object
|
398
|
+
*/
|
399
|
+
login(text, loginUrl) {
|
400
|
+
return this.add({
|
401
|
+
text,
|
402
|
+
login_url: typeof loginUrl === "string"
|
403
|
+
? { url: loginUrl }
|
404
|
+
: loginUrl,
|
405
|
+
});
|
406
|
+
}
|
407
|
+
/**
|
408
|
+
* Adds a new inline query button. Telegram clients will let the user pick a
|
409
|
+
* chat when this button is pressed. This will start an inline query. The
|
410
|
+
* selected chat will be prefilled with the name of your bot. You may
|
411
|
+
* provide a text that is specified along with it.
|
412
|
+
*
|
413
|
+
* Your bot will in turn receive updates for inline queries. You can listen
|
414
|
+
* to inline query updates like this:
|
415
|
+
*
|
416
|
+
* ```ts
|
417
|
+
* // Listen for specifc query
|
418
|
+
* bot.inlineQuery('my-query', ctx => { ... })
|
419
|
+
* // Listen for any query
|
420
|
+
* bot.on('inline_query', ctx => { ... })
|
421
|
+
* ```
|
422
|
+
*
|
423
|
+
* Technically, it is also possible to wait for an inline query inside the
|
424
|
+
* conversation using `conversation.waitFor('inline_query')`. However,
|
425
|
+
* updates about inline queries do not contain a chat identifier. Hence, it
|
426
|
+
* is typically not possible to handle them inside a conversation, as
|
427
|
+
* conversation data is stored per chat by default.
|
428
|
+
*
|
429
|
+
* @param text The text to display
|
430
|
+
* @param query The (optional) inline query string to prefill
|
431
|
+
*/
|
432
|
+
switchInline(text, query = "") {
|
433
|
+
return this.add({ text, switch_inline_query: query });
|
434
|
+
}
|
435
|
+
/**
|
436
|
+
* Adds a new inline query button that acts on the current chat. The
|
437
|
+
* selected chat will be prefilled with the name of your bot. You may
|
438
|
+
* provide a text that is specified along with it. This will start an inline
|
439
|
+
* query.
|
440
|
+
*
|
441
|
+
* Your bot will in turn receive updates for inline queries. You can listen
|
442
|
+
* to inline query updates like this:
|
443
|
+
*
|
444
|
+
* ```ts
|
445
|
+
* // Listen for specifc query
|
446
|
+
* bot.inlineQuery('my-query', ctx => { ... })
|
447
|
+
* // Listen for any query
|
448
|
+
* bot.on('inline_query', ctx => { ... })
|
449
|
+
* ```
|
450
|
+
*
|
451
|
+
* Technically, it is also possible to wait for an inline query inside the
|
452
|
+
* conversation using `conversation.waitFor('inline_query')`. However,
|
453
|
+
* updates about inline queries do not contain a chat identifier. Hence, it
|
454
|
+
* is typically not possible to handle them inside a conversation, as
|
455
|
+
* conversation data is stored per chat by default.
|
456
|
+
*
|
457
|
+
* @param text The text to display
|
458
|
+
* @param query The (optional) inline query string to prefill
|
459
|
+
*/
|
460
|
+
switchInlineCurrent(text, query = "") {
|
461
|
+
return this.add({ text, switch_inline_query_current_chat: query });
|
462
|
+
}
|
463
|
+
/**
|
464
|
+
* Adds a new inline query button. Telegram clients will let the user pick a
|
465
|
+
* chat when this button is pressed. This will start an inline query. The
|
466
|
+
* selected chat will be prefilled with the name of your bot. You may
|
467
|
+
* provide a text that is specified along with it.
|
468
|
+
*
|
469
|
+
* Your bot will in turn receive updates for inline queries. You can listen
|
470
|
+
* to inline query updates like this:
|
471
|
+
* ```ts
|
472
|
+
* bot.on('inline_query', ctx => { ... })
|
473
|
+
* ```
|
474
|
+
*
|
475
|
+
* Technically, it is also possible to wait for an inline query inside the
|
476
|
+
* conversation using `conversation.waitFor('inline_query')`. However,
|
477
|
+
* updates about inline queries do not contain a chat identifier. Hence, it
|
478
|
+
* is typically not possible to handle them inside a conversation, as
|
479
|
+
* conversation data is stored per chat by default.
|
480
|
+
*
|
481
|
+
* @param text The text to display
|
482
|
+
* @param query The query object describing which chats can be picked
|
483
|
+
*/
|
484
|
+
switchInlineChosen(text, query = {}) {
|
485
|
+
return this.add({ text, switch_inline_query_chosen_chat: query });
|
486
|
+
}
|
487
|
+
/**
|
488
|
+
* Adds a new copy text button. When clicked, the specified text will be
|
489
|
+
* copied to the clipboard.
|
490
|
+
*
|
491
|
+
* @param text The text to display
|
492
|
+
* @param copyText The text to be copied to the clipboard
|
493
|
+
*/
|
494
|
+
copyText(text, copyText) {
|
495
|
+
return this.add({
|
496
|
+
text,
|
497
|
+
copy_text: typeof copyText === "string"
|
498
|
+
? { text: copyText }
|
499
|
+
: copyText,
|
500
|
+
});
|
501
|
+
}
|
502
|
+
/**
|
503
|
+
* Adds a new game query button, confer
|
504
|
+
* https://core.telegram.org/bots/api#games
|
505
|
+
*
|
506
|
+
* This type of button must always be the first button in the first row.
|
507
|
+
*
|
508
|
+
* @param text The text to display
|
509
|
+
*/
|
510
|
+
game(text) {
|
511
|
+
return this.add({ text, callback_game: {} });
|
512
|
+
}
|
513
|
+
/**
|
514
|
+
* Adds a new payment button, confer
|
515
|
+
* https://core.telegram.org/bots/api#payments
|
516
|
+
*
|
517
|
+
* This type of button must always be the first button in the first row and can only be used in invoice messages.
|
518
|
+
*
|
519
|
+
* @param text The text to display
|
520
|
+
*/
|
521
|
+
pay(text) {
|
522
|
+
return this.add({ text, pay: true });
|
523
|
+
}
|
524
|
+
submenu(text, menu, ...middleware) {
|
525
|
+
return this.text(text, middleware.length === 0
|
526
|
+
? (ctx) => ctx.menu.nav(menu)
|
527
|
+
: (ctx, next) => (ctx.menu.nav(menu), next()), ...middleware);
|
528
|
+
}
|
529
|
+
back(text, ...middleware) {
|
530
|
+
return this.text(text, middleware.length === 0
|
531
|
+
? (ctx) => ctx.menu.back()
|
532
|
+
: (ctx, next) => (ctx.menu.back(), next()), ...middleware);
|
533
|
+
}
|
534
|
+
/**
|
535
|
+
* This is a dynamic way to initialize the conversational menu. A typical
|
536
|
+
* use case is when you want to create an arbitrary conversational menu,
|
537
|
+
* using the data from your database:
|
538
|
+
*
|
539
|
+
* ```ts
|
540
|
+
* const menu = conversation.menu()
|
541
|
+
* const data = await conversation.external(() => fetchDataFromDatabase())
|
542
|
+
* menu.dynamic(ctx => data.reduce((range, entry) => range.text(entry)), new ConversationMenuRange())
|
543
|
+
* await ctx.reply("Menu", { reply_markup: menu })
|
544
|
+
* ```
|
545
|
+
*
|
546
|
+
* @param menuFactory Async menu factory function
|
547
|
+
*/
|
548
|
+
dynamic(rangeBuilder) {
|
549
|
+
return this.addRange(async (ctx) => {
|
550
|
+
const range = new ConversationMenuRange();
|
551
|
+
const res = await rangeBuilder(ctx, range);
|
552
|
+
if (res instanceof ConversationMenu) {
|
553
|
+
throw new Error("Cannot use a `Menu` instance as a dynamic range, did you mean to return an instance of `MenuRange` instead?");
|
554
|
+
}
|
555
|
+
return res instanceof ConversationMenuRange ? res : range;
|
556
|
+
});
|
557
|
+
}
|
558
|
+
/**
|
559
|
+
* Appends a given range to this range. This will effectively replay all
|
560
|
+
* operations of the given range onto this range.
|
561
|
+
*
|
562
|
+
* @param range A potentially raw range
|
563
|
+
*/
|
564
|
+
append(range) {
|
565
|
+
if (range instanceof ConversationMenuRange) {
|
566
|
+
this[ops].push(...range[ops]);
|
567
|
+
return this;
|
568
|
+
}
|
569
|
+
else
|
570
|
+
return this.addRange(range);
|
571
|
+
}
|
572
|
+
}
|
573
|
+
_a = ops;
|
574
|
+
/**
|
575
|
+
* A conversational menu is a set of interactive buttons that is displayed
|
576
|
+
* beneath a message. It uses an [inline
|
577
|
+
* keyboard](https://grammy.dev/plugins/keyboard.html) for that, so in a sense,
|
578
|
+
* a conversational menu is just an inline keyboard spiced up with interactivity
|
579
|
+
* (such as navigation between multiple pages).
|
580
|
+
*
|
581
|
+
* ```ts
|
582
|
+
* // Create a simple conversational menu
|
583
|
+
* const menu = conversation.menu()
|
584
|
+
* .text('A', ctx => ctx.reply('You pressed A!')).row()
|
585
|
+
* .text('B', ctx => ctx.reply('You pressed B!'))
|
586
|
+
*
|
587
|
+
* // Send the conversational menu
|
588
|
+
* await ctx.reply('Check out this menu:', { reply_markup: menu })
|
589
|
+
* ```
|
590
|
+
*
|
591
|
+
* Check out the [official
|
592
|
+
* documentation](https://grammy.dev/plugins/conversations) to see how you can
|
593
|
+
* create menus that span several pages, how to navigate between them, and more.
|
594
|
+
*/
|
595
|
+
export class ConversationMenu extends ConversationMenuRange {
|
596
|
+
constructor(id, options = {}) {
|
597
|
+
var _b, _c;
|
598
|
+
super();
|
599
|
+
this.id = id;
|
600
|
+
this.inline_keyboard = youTouchYouDie("Something went very wrong, how did you manage to run into this error?");
|
601
|
+
this[opts] = {
|
602
|
+
parent: options.parent,
|
603
|
+
autoAnswer: (_b = options.autoAnswer) !== null && _b !== void 0 ? _b : true,
|
604
|
+
fingerprint: (_c = options.fingerprint) !== null && _c !== void 0 ? _c : (() => ""),
|
605
|
+
};
|
606
|
+
}
|
607
|
+
}
|
608
|
+
function createRenderer(ctx, buttonTransformer) {
|
609
|
+
async function layout(keyboard, range) {
|
610
|
+
const k = await keyboard;
|
611
|
+
// Make static
|
612
|
+
const btns = typeof range === "function" ? await range(ctx) : range;
|
613
|
+
// Make raw
|
614
|
+
if (btns instanceof ConversationMenuRange) {
|
615
|
+
return btns[ops].reduce(layout, keyboard);
|
616
|
+
}
|
617
|
+
// Replay new buttons on top of partially constructed keyboard
|
618
|
+
let first = true;
|
619
|
+
for (const row of btns) {
|
620
|
+
if (!first)
|
621
|
+
k.push([]);
|
622
|
+
const i = k.length - 1;
|
623
|
+
for (const button of row) {
|
624
|
+
const j = k[i].length;
|
625
|
+
const btn = await buttonTransformer(button, i, j);
|
626
|
+
k[i].push(btn);
|
627
|
+
}
|
628
|
+
first = false;
|
629
|
+
}
|
630
|
+
return k;
|
631
|
+
}
|
632
|
+
return (ops) => ops.reduce(layout, Promise.resolve([[]]));
|
633
|
+
}
|
634
|
+
function createDisplayRenderer(id, ctx) {
|
635
|
+
return createRenderer(ctx, async (btn, i, j) => {
|
636
|
+
const text = await uniform(ctx, btn.text);
|
637
|
+
if ("url" in btn) {
|
638
|
+
let { url, ...rest } = btn;
|
639
|
+
url = await uniform(ctx, btn.url);
|
640
|
+
return { ...rest, url, text };
|
641
|
+
}
|
642
|
+
else if ("middleware" in btn) {
|
643
|
+
const row = i.toString(16);
|
644
|
+
const col = j.toString(16);
|
645
|
+
const payload = await uniform(ctx, btn.payload, "");
|
646
|
+
if (payload.includes("/")) {
|
647
|
+
throw new Error(`Could not render menu '${id}'! Payload must not contain a '/' character but was '${payload}'`);
|
648
|
+
}
|
649
|
+
return {
|
650
|
+
callback_data: `${id}/${row}/${col}/${payload}/`,
|
651
|
+
text,
|
652
|
+
};
|
653
|
+
}
|
654
|
+
else
|
655
|
+
return { ...btn, text };
|
656
|
+
});
|
657
|
+
}
|
658
|
+
function createHandlerRenderer(ctx) {
|
659
|
+
return createRenderer(ctx, (btn) => btn);
|
660
|
+
}
|
661
|
+
/**
|
662
|
+
* Turns an optional and potentially dynamic string into a regular string for a
|
663
|
+
* given context object.
|
664
|
+
*
|
665
|
+
* @param ctx Context object
|
666
|
+
* @param value Potentially dynamic string
|
667
|
+
* @param fallback Fallback string value if value is undefined
|
668
|
+
* @returns Plain old string
|
669
|
+
*/
|
670
|
+
function uniform(ctx, value, fallback = "") {
|
671
|
+
if (value === undefined)
|
672
|
+
return fallback;
|
673
|
+
else if (typeof value === "function")
|
674
|
+
return value(ctx);
|
675
|
+
else
|
676
|
+
return value;
|
677
|
+
}
|
678
|
+
function appendHashes(keyboard, fingerprint) {
|
679
|
+
const lengths = [keyboard.length, ...keyboard.map((row) => row.length)];
|
680
|
+
for (const row of keyboard) {
|
681
|
+
for (const btn of row) {
|
682
|
+
if ("callback_data" in btn) {
|
683
|
+
// Inject hash values to detect keyboard changes
|
684
|
+
let type;
|
685
|
+
let data;
|
686
|
+
if (fingerprint) {
|
687
|
+
type = "f";
|
688
|
+
data = toNums(fingerprint);
|
689
|
+
}
|
690
|
+
else {
|
691
|
+
type = "h";
|
692
|
+
data = [...lengths, ...toNums(btn.text)];
|
693
|
+
}
|
694
|
+
btn.callback_data += type + tinyHash(data);
|
695
|
+
}
|
696
|
+
}
|
697
|
+
}
|
698
|
+
}
|