@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/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
+ }