grambot 1.0.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/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/action/action.d.ts +17 -0
- package/dist/action/action.d.ts.map +1 -0
- package/dist/action/action.js +59 -0
- package/dist/action/action.js.map +1 -0
- package/dist/conversation/conversation.d.ts +15 -0
- package/dist/conversation/conversation.d.ts.map +1 -0
- package/dist/conversation/conversation.js +346 -0
- package/dist/conversation/conversation.js.map +1 -0
- package/dist/engine/engine.d.ts +24 -0
- package/dist/engine/engine.d.ts.map +1 -0
- package/dist/engine/engine.js +847 -0
- package/dist/engine/engine.js.map +1 -0
- package/dist/grambot.d.ts +142 -0
- package/dist/grambot.d.ts.map +1 -0
- package/dist/grambot.js +204 -0
- package/dist/grambot.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/menu/button.d.ts +99 -0
- package/dist/menu/button.d.ts.map +1 -0
- package/dist/menu/button.js +157 -0
- package/dist/menu/button.js.map +1 -0
- package/dist/menu/layout.d.ts +86 -0
- package/dist/menu/layout.d.ts.map +1 -0
- package/dist/menu/layout.js +90 -0
- package/dist/menu/layout.js.map +1 -0
- package/dist/menu/list.d.ts +43 -0
- package/dist/menu/list.d.ts.map +1 -0
- package/dist/menu/list.js +60 -0
- package/dist/menu/list.js.map +1 -0
- package/dist/menu/menu.d.ts +29 -0
- package/dist/menu/menu.d.ts.map +1 -0
- package/dist/menu/menu.js +73 -0
- package/dist/menu/menu.js.map +1 -0
- package/dist/telebot.d.ts +142 -0
- package/dist/telebot.d.ts.map +1 -0
- package/dist/telebot.js +204 -0
- package/dist/telebot.js.map +1 -0
- package/dist/types.d.ts +322 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/ui.d.ts +12 -0
- package/dist/ui/ui.d.ts.map +1 -0
- package/dist/ui/ui.js +39 -0
- package/dist/ui/ui.js.map +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
import { InlineKeyboard, session } from "grammy";
|
|
2
|
+
import { conversations, createConversation } from "@grammyjs/conversations";
|
|
3
|
+
import { LayoutBuilder } from "../menu/layout.js";
|
|
4
|
+
import { isActionRef } from "../menu/button.js";
|
|
5
|
+
import { createConversationHelper } from "../conversation/conversation.js";
|
|
6
|
+
import { createUIHelper } from "../ui/ui.js";
|
|
7
|
+
import { getGlobalActions } from "../action/action.js";
|
|
8
|
+
import { getGlobalMenus } from "../menu/menu.js";
|
|
9
|
+
// ─── Pagination state (in-memory, per-chat, per-menu-list) ────────────────────
|
|
10
|
+
/**
|
|
11
|
+
* Maps `chatId:menuId:listIdx` to the current page number.
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
const pageState = new Map();
|
|
15
|
+
/** @internal */
|
|
16
|
+
function pageKey(chatId, menuId, listIdx) {
|
|
17
|
+
return `${chatId}:${menuId}:${listIdx}`;
|
|
18
|
+
}
|
|
19
|
+
// ─── Resolve dynamic label ─────────────────────────────────────────────────────
|
|
20
|
+
/** @internal */
|
|
21
|
+
function resolveLabel(config, ctx, translator) {
|
|
22
|
+
if (typeof config.label === "function") {
|
|
23
|
+
return config.label(ctx);
|
|
24
|
+
}
|
|
25
|
+
return translator ? translator(config.label, ctx) : config.label;
|
|
26
|
+
}
|
|
27
|
+
// ─── Check guard ───────────────────────────────────────────────────────────────
|
|
28
|
+
/** @internal */
|
|
29
|
+
async function passesGuard(guard, ctx) {
|
|
30
|
+
if (!guard)
|
|
31
|
+
return true;
|
|
32
|
+
return guard(ctx);
|
|
33
|
+
}
|
|
34
|
+
// ─── Apply button style and icon ───────────────────────────────────────────────
|
|
35
|
+
/** @internal */
|
|
36
|
+
function applyButtonStyling(keyboard, style, iconCustomEmojiId) {
|
|
37
|
+
if (style) {
|
|
38
|
+
keyboard.style(style);
|
|
39
|
+
}
|
|
40
|
+
if (iconCustomEmojiId) {
|
|
41
|
+
keyboard.icon(iconCustomEmojiId);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function resolveStableId(menuId, cfg, index) {
|
|
45
|
+
if (cfg.buttonId)
|
|
46
|
+
return `a${cfg.buttonId}`;
|
|
47
|
+
return `a${menuId}_${index}`;
|
|
48
|
+
}
|
|
49
|
+
async function collectMenuTree(rootRef, menus, actions, parentId) {
|
|
50
|
+
if (menus.has(rootRef.id))
|
|
51
|
+
return;
|
|
52
|
+
const layout = new LayoutBuilder();
|
|
53
|
+
// Provide a minimal mock context for scanning (guards might fail, but we try to find actions)
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
55
|
+
const mockCtx = { user: {}, session: { conversation: {} } };
|
|
56
|
+
await rootRef.builder(layout, mockCtx);
|
|
57
|
+
menus.set(rootRef.id, { ref: rootRef, layout, parent: parentId });
|
|
58
|
+
// Use a snapshot to avoid infinite loops if we were rendering (though here we just scan)
|
|
59
|
+
let btnIndex = 0;
|
|
60
|
+
for (const el of [...layout._elements]) {
|
|
61
|
+
if (el.kind === "button") {
|
|
62
|
+
const cfg = el.builder._config;
|
|
63
|
+
if (cfg.action && isActionRef(cfg.action)) {
|
|
64
|
+
actions.set(cfg.action.id, cfg.action);
|
|
65
|
+
}
|
|
66
|
+
else if (cfg.inlineHandler) {
|
|
67
|
+
// Register inline handler with stable ID
|
|
68
|
+
const id = resolveStableId(rootRef.id, cfg, btnIndex);
|
|
69
|
+
actions.set(id, {
|
|
70
|
+
id,
|
|
71
|
+
handler: cfg.inlineHandler,
|
|
72
|
+
__Grambot_action: true,
|
|
73
|
+
command() { return this; },
|
|
74
|
+
word() { return this; },
|
|
75
|
+
regexp() { return this; },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (cfg.submenu) {
|
|
79
|
+
await collectMenuTree(cfg.submenu, menus, actions, rootRef.id);
|
|
80
|
+
}
|
|
81
|
+
btnIndex++;
|
|
82
|
+
}
|
|
83
|
+
if (el.kind === "list") {
|
|
84
|
+
// Create a dummy item to find out if the render function produces actions
|
|
85
|
+
const listBuilder = el.builder;
|
|
86
|
+
if (listBuilder._config.renderFn && listBuilder._config.items.length > 0) {
|
|
87
|
+
// We warn: resolving actions from dynamic lists is best-effort during collection
|
|
88
|
+
// True runtime resolution happens when the button is actually clicked
|
|
89
|
+
const msg = "Note: Actions inside dynamic lists are registered lazily.";
|
|
90
|
+
// Ideally we should dry-run the render, but we can't easily without context.
|
|
91
|
+
// For now, we rely on the fact that most actions are reused or declared statically.
|
|
92
|
+
// If an action is ONLY used in a list, we might miss it here?
|
|
93
|
+
// Fix: Render the first item to see what action it uses.
|
|
94
|
+
try {
|
|
95
|
+
// We can't render without modifying layout, but we can ignore modifications here
|
|
96
|
+
const dummyItem = listBuilder._config.items[0];
|
|
97
|
+
const btn = listBuilder._config.renderFn(dummyItem);
|
|
98
|
+
// The renderFn likely pushed to `layout`, but since we iterate a snapshot, it's explicitly safe.
|
|
99
|
+
if (btn._config.action && isActionRef(btn._config.action)) {
|
|
100
|
+
actions.set(btn._config.action.id, btn._config.action);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Ignore render errors during scan
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// ─── Build inline keyboard for a menu ──────────────────────────────────────────
|
|
111
|
+
async function buildKeyboard(menuId, layout, ctx, chatId, translator, backToMenuId, currentPayload) {
|
|
112
|
+
const keyboard = new InlineKeyboard();
|
|
113
|
+
let text = "";
|
|
114
|
+
let imageUrl;
|
|
115
|
+
let parseMode;
|
|
116
|
+
const maxPerRow = layout._maxPerRow;
|
|
117
|
+
let currentRowCount = 0;
|
|
118
|
+
let listIdx = 0;
|
|
119
|
+
let btnIndex = 0;
|
|
120
|
+
// 1. Pre-scan: Find default tab and run its action (to populate text)
|
|
121
|
+
// We do this BEFORE snapshotting so the text element is included in iteration
|
|
122
|
+
for (const el of layout._elements) {
|
|
123
|
+
if (el.kind === "button" && el.builder._config.isDefault) {
|
|
124
|
+
const cfg = el.builder._config;
|
|
125
|
+
// If it's a tab action (not a nav/act/link), run it
|
|
126
|
+
if (cfg.action && !isActionRef(cfg.action)) {
|
|
127
|
+
cfg.action();
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// 2. Snapshot: Capture elements (including the newly added text from tab action)
|
|
133
|
+
const snapshotElements = [...layout._elements];
|
|
134
|
+
for (const el of snapshotElements) {
|
|
135
|
+
switch (el.kind) {
|
|
136
|
+
case "text": {
|
|
137
|
+
text = translator ? translator(el.content, ctx, el.replace) : el.content;
|
|
138
|
+
parseMode = el.parseMode;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case "image": {
|
|
142
|
+
imageUrl = el.url;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case "button": {
|
|
146
|
+
const cfg = el.builder._config;
|
|
147
|
+
// Capture stable index based on layout order (regardless of visibility)
|
|
148
|
+
const myIndex = btnIndex++;
|
|
149
|
+
if (!(await passesGuard(cfg.guard, ctx)))
|
|
150
|
+
break;
|
|
151
|
+
const label = resolveLabel(cfg, ctx, translator);
|
|
152
|
+
const btnId = cfg.buttonId ?? label;
|
|
153
|
+
if (cfg.forceRow || (maxPerRow > 0 && currentRowCount >= maxPerRow)) {
|
|
154
|
+
keyboard.row();
|
|
155
|
+
currentRowCount = 0;
|
|
156
|
+
}
|
|
157
|
+
// Determine callback data
|
|
158
|
+
let cbData;
|
|
159
|
+
if (cfg.url) {
|
|
160
|
+
keyboard.url(label, cfg.url);
|
|
161
|
+
applyButtonStyling(keyboard, cfg.style, cfg.iconCustomEmojiId);
|
|
162
|
+
currentRowCount++;
|
|
163
|
+
break; // URL buttons don't have callback data
|
|
164
|
+
}
|
|
165
|
+
else if (cfg.inlineHandler) {
|
|
166
|
+
// Use stable ID for inline conversational action
|
|
167
|
+
const id = resolveStableId(menuId, cfg, myIndex);
|
|
168
|
+
const p = cfg.payload !== undefined ? cfg.payload : currentPayload;
|
|
169
|
+
// Use button ID as payload if no payload is provided
|
|
170
|
+
const pStr = p && typeof p === "object" && Object.keys(p).length === 1 && "id" in p ? String(p.id) : (p !== undefined ? JSON.stringify(p) : (cfg.buttonId || ""));
|
|
171
|
+
// If the "menu" is actually an action, we use 'ai:' (Action Inline)
|
|
172
|
+
// because these are not pre-registered as conversations during scan.
|
|
173
|
+
const prefix = menuId.startsWith("a") ? "ai:" : "a:";
|
|
174
|
+
cbData = `${prefix}${id}/${menuId}:${pStr}`;
|
|
175
|
+
}
|
|
176
|
+
else if (cfg.submenu) {
|
|
177
|
+
cbData = `n:${cfg.submenu.id}`;
|
|
178
|
+
}
|
|
179
|
+
else if (cfg.action && isActionRef(cfg.action)) {
|
|
180
|
+
const p = cfg.payload;
|
|
181
|
+
// Use button ID as payload if no payload is provided
|
|
182
|
+
const pStr = p && Object.keys(p).length === 1 && "id" in p ? String(p.id) : (p ? JSON.stringify(p) : (cfg.buttonId || ""));
|
|
183
|
+
cbData = `a:${cfg.action.id}/${menuId}:${pStr}`;
|
|
184
|
+
}
|
|
185
|
+
else if (cfg.action && !isActionRef(cfg.action)) {
|
|
186
|
+
// Tab-style inline action (sync)
|
|
187
|
+
cbData = `t:${menuId}:${btnId}`;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
cbData = `_:${menuId}:${btnId}`;
|
|
191
|
+
}
|
|
192
|
+
// Mark active tab visually if possible?
|
|
193
|
+
// Example: add brackets or checkmark?
|
|
194
|
+
// User didn't ask for it, but scenarios often implies it.
|
|
195
|
+
// For now keep label as is.
|
|
196
|
+
keyboard.text(label, cbData);
|
|
197
|
+
applyButtonStyling(keyboard, cfg.style, cfg.iconCustomEmojiId);
|
|
198
|
+
currentRowCount++;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
case "list": {
|
|
202
|
+
const listBuilder = el.builder;
|
|
203
|
+
const lcfg = listBuilder._config;
|
|
204
|
+
if (!lcfg.renderFn)
|
|
205
|
+
break;
|
|
206
|
+
const pk = pageKey(chatId, menuId, listIdx);
|
|
207
|
+
let currentPage = pageState.get(pk) ?? 0;
|
|
208
|
+
const totalPages = Math.ceil(lcfg.items.length / lcfg.itemsPerPage);
|
|
209
|
+
// Fix: if current page is out of bounds (e.g. items deleted), go to last valid page
|
|
210
|
+
if (currentPage >= totalPages) {
|
|
211
|
+
currentPage = Math.max(0, totalPages - 1);
|
|
212
|
+
pageState.set(pk, currentPage);
|
|
213
|
+
}
|
|
214
|
+
const startIdx = currentPage * lcfg.itemsPerPage;
|
|
215
|
+
const pageItems = lcfg.items.slice(startIdx, startIdx + lcfg.itemsPerPage);
|
|
216
|
+
// Reset row for list items
|
|
217
|
+
if (currentRowCount > 0) {
|
|
218
|
+
keyboard.row();
|
|
219
|
+
currentRowCount = 0;
|
|
220
|
+
}
|
|
221
|
+
let colCount = 0;
|
|
222
|
+
for (const item of pageItems) {
|
|
223
|
+
if (colCount > 0 && colCount >= lcfg.columns) {
|
|
224
|
+
keyboard.row();
|
|
225
|
+
colCount = 0;
|
|
226
|
+
}
|
|
227
|
+
const btn = lcfg.renderFn(item);
|
|
228
|
+
// Apply default action from list if button has none
|
|
229
|
+
if (!btn._config.action && !btn._config.inlineHandler && !btn._config.submenu && lcfg.action) {
|
|
230
|
+
btn.action(lcfg.action);
|
|
231
|
+
}
|
|
232
|
+
const bcfg = btn._config;
|
|
233
|
+
const label = resolveLabel(bcfg, ctx, translator);
|
|
234
|
+
const btnId = bcfg.buttonId ?? label;
|
|
235
|
+
let cbData;
|
|
236
|
+
if (bcfg.action && isActionRef(bcfg.action)) {
|
|
237
|
+
const p = bcfg.payload !== undefined ? bcfg.payload : currentPayload;
|
|
238
|
+
// Use button ID as payload if no payload is provided
|
|
239
|
+
const pStr = p && typeof p === "object" && Object.keys(p).length === 1 && "id" in p ? String(p.id) : (p !== undefined ? JSON.stringify(p) : (bcfg.buttonId || ""));
|
|
240
|
+
cbData = `a:${bcfg.action.id}/${menuId}:${pStr}`;
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
cbData = `_:${menuId}:${btnId}`;
|
|
244
|
+
}
|
|
245
|
+
keyboard.text(label, cbData);
|
|
246
|
+
applyButtonStyling(keyboard, bcfg.style, bcfg.iconCustomEmojiId);
|
|
247
|
+
colCount++;
|
|
248
|
+
}
|
|
249
|
+
// Pagination controls
|
|
250
|
+
if (totalPages > 1) {
|
|
251
|
+
keyboard.row();
|
|
252
|
+
if (currentPage > 0) {
|
|
253
|
+
keyboard.text("⬅️", `p:${menuId}:${listIdx}:${currentPage - 1}`);
|
|
254
|
+
}
|
|
255
|
+
keyboard.text(`${currentPage + 1}/${totalPages}`, `_:${menuId}:page-info`);
|
|
256
|
+
if (currentPage < totalPages - 1) {
|
|
257
|
+
keyboard.text("➡️", `p:${menuId}:${listIdx}:${currentPage + 1}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
listIdx++;
|
|
261
|
+
keyboard.row();
|
|
262
|
+
currentRowCount = 0;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
case "refresh": {
|
|
266
|
+
keyboard.row();
|
|
267
|
+
const label = translator ? translator(el.label, ctx) : el.label;
|
|
268
|
+
keyboard.text(label, `r:${menuId}`);
|
|
269
|
+
currentRowCount = 0;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Always append back button if parent exists
|
|
275
|
+
if (backToMenuId) {
|
|
276
|
+
keyboard.row();
|
|
277
|
+
const backText = translator ? translator("grambot.back", ctx) : "◀️ Back";
|
|
278
|
+
keyboard.text(backText, `n:${backToMenuId}`);
|
|
279
|
+
}
|
|
280
|
+
return { text: text || "Menu", keyboard, parseMode, imageUrl };
|
|
281
|
+
}
|
|
282
|
+
// ─── Engine ────────────────────────────────────────────────────────────────────
|
|
283
|
+
/**
|
|
284
|
+
* Installs the Grambot engine onto a Grammy bot.
|
|
285
|
+
*
|
|
286
|
+
* - Registers session and conversation plugins.
|
|
287
|
+
* - Resolves and registers all menus and actions.
|
|
288
|
+
* - Sets up text and callback query handlers.
|
|
289
|
+
*
|
|
290
|
+
* @param bot - The grammy bot instance.
|
|
291
|
+
* @param rootRef - The root menu of the bot.
|
|
292
|
+
* @param config - Framework configuration.
|
|
293
|
+
*/
|
|
294
|
+
export async function installMenu(bot, rootRef, config) {
|
|
295
|
+
const menus = new Map();
|
|
296
|
+
const actions = new Map();
|
|
297
|
+
// We cannot wait for initial collection here because installMenu MUST be sync
|
|
298
|
+
// to be used in constructor. We'll lazy-collect or do it in start().
|
|
299
|
+
// 0. Collect the menu tree (recursively find all menus and actions reachable from root)
|
|
300
|
+
await collectMenuTree(rootRef, menus, actions);
|
|
301
|
+
// 0.5. Register global actions and menus that might have been missed by recursive scan (orphan items)
|
|
302
|
+
for (const action of getGlobalActions()) {
|
|
303
|
+
if (!actions.has(action.id)) {
|
|
304
|
+
actions.set(action.id, action);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
for (const menu of getGlobalMenus()) {
|
|
308
|
+
if (!menus.has(menu.id)) {
|
|
309
|
+
// For orphan menus, we don't know the parent, so parent is undefined
|
|
310
|
+
// We still need a LayoutBuilder to handle it correctly in renderMenu
|
|
311
|
+
const layout = new LayoutBuilder();
|
|
312
|
+
menus.set(menu.id, { ref: menu, layout, parent: undefined });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// 1. Install Session Middleware (Required for Conversations)
|
|
316
|
+
bot.use(session({
|
|
317
|
+
initial: () => ({
|
|
318
|
+
conversation: {},
|
|
319
|
+
originMenuId: undefined,
|
|
320
|
+
__Grambot_payload: undefined, // Explicitly include for persistence safety
|
|
321
|
+
}),
|
|
322
|
+
storage: config.sessionStorage,
|
|
323
|
+
}));
|
|
324
|
+
// 1.5. Middleware: resolve user (Moved to prevent undefined ctx.user in guards/conversations)
|
|
325
|
+
bot.use(async (ctx, next) => {
|
|
326
|
+
if (config.resolveUser) {
|
|
327
|
+
try {
|
|
328
|
+
ctx.user = await config.resolveUser(ctx) ?? {};
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
console.error("[Grambot] Error in resolveUser middleware:", e);
|
|
332
|
+
ctx.user = {};
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
ctx.user = ctx.user ?? {};
|
|
337
|
+
}
|
|
338
|
+
await next();
|
|
339
|
+
});
|
|
340
|
+
// 2. Install Conversations Plugin (Injects ctx.conversation)
|
|
341
|
+
bot.use(conversations());
|
|
342
|
+
// 3. Register Action Handlers as Conversations (MUST BE REGISTERED BEFORE usage in middleware below)
|
|
343
|
+
for (const [actionId, actionRef] of actions) {
|
|
344
|
+
const convBuilder = async (conversation, ctx) => {
|
|
345
|
+
// 1. Resolve payload
|
|
346
|
+
// Priority:
|
|
347
|
+
// A. Replay session (already persisted)
|
|
348
|
+
// B. Context session (first run trigger)
|
|
349
|
+
// C. Context match (first run regex trigger)
|
|
350
|
+
// D. Callback data (first run button trigger)
|
|
351
|
+
// E. Manual re-matching (last resort fallback)
|
|
352
|
+
let payload = conversation.session?.__Grambot_payload;
|
|
353
|
+
if (payload === undefined) {
|
|
354
|
+
const session = ctx.session;
|
|
355
|
+
if (session?.__Grambot_payload !== undefined) {
|
|
356
|
+
payload = session.__Grambot_payload;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (payload === undefined && ctx.match && Array.isArray(ctx.match) && ctx.match.length > 1) {
|
|
360
|
+
const val = ctx.match[1];
|
|
361
|
+
payload = { id: parseInt(val, 10) || val };
|
|
362
|
+
}
|
|
363
|
+
if (payload === undefined && ctx.message?.text && actionRef.triggers?.regexps) {
|
|
364
|
+
for (const re of actionRef.triggers.regexps) {
|
|
365
|
+
const m = ctx.message.text.match(re);
|
|
366
|
+
if (m && m.length > 1) {
|
|
367
|
+
const val = m[1];
|
|
368
|
+
payload = { id: parseInt(val, 10) || val };
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (payload === undefined && ctx.callbackQuery?.data?.startsWith("a:")) {
|
|
374
|
+
const parts = ctx.callbackQuery.data.split("/");
|
|
375
|
+
if (parts.length > 1) {
|
|
376
|
+
const rest = parts[1];
|
|
377
|
+
const colonIdx = rest.indexOf(":");
|
|
378
|
+
const pStr = colonIdx !== -1 ? rest.slice(colonIdx + 1) : "";
|
|
379
|
+
if (pStr) {
|
|
380
|
+
if (pStr.startsWith("{")) {
|
|
381
|
+
try {
|
|
382
|
+
payload = JSON.parse(pStr);
|
|
383
|
+
}
|
|
384
|
+
catch { }
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
// Raw ID optimization: if it looks like a number, parse it, else keep as string
|
|
388
|
+
payload = { id: isNaN(Number(pStr)) ? pStr : Number(pStr) };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// 2. Persist found payload to conversation session for future replays
|
|
394
|
+
if (payload !== undefined) {
|
|
395
|
+
const convSession = conversation.session;
|
|
396
|
+
if (convSession) {
|
|
397
|
+
if (convSession.__Grambot_payload === undefined) {
|
|
398
|
+
convSession.__Grambot_payload = payload;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
conversation.session = { __Grambot_payload: payload };
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const navigate = async (menu) => {
|
|
406
|
+
const targetId = menu ? menu.id : rootRef.id;
|
|
407
|
+
const targetMessageId = conversation.session?.__Grambot_last_msg_id;
|
|
408
|
+
await conversation.external(async (c) => {
|
|
409
|
+
// Re-resolve user to pick up changes made during the action (e.g. language change)
|
|
410
|
+
if (config.resolveUser) {
|
|
411
|
+
try {
|
|
412
|
+
c.user = await config.resolveUser(c) ?? {};
|
|
413
|
+
}
|
|
414
|
+
catch (e) {
|
|
415
|
+
console.error("[Grambot] Error re-resolving user during navigation:", e);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
await renderMenu(targetId, c, c.chat.id, true, undefined, targetMessageId);
|
|
419
|
+
});
|
|
420
|
+
};
|
|
421
|
+
const conversationHelper = createConversationHelper(conversation, ctx, config.translator, navigate);
|
|
422
|
+
const uiHelper = createUIHelper(ctx);
|
|
423
|
+
// Robust user resolution: if missing or empty, try re-resolving
|
|
424
|
+
if (!ctx.user || Object.keys(ctx.user).length === 0) {
|
|
425
|
+
if (config.resolveUser) {
|
|
426
|
+
try {
|
|
427
|
+
ctx.user = await config.resolveUser(ctx) ?? {};
|
|
428
|
+
}
|
|
429
|
+
catch (e) {
|
|
430
|
+
console.error("[Grambot] Error in resolveUser inside conversation:", e);
|
|
431
|
+
ctx.user = {};
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
ctx.user = ctx.user ?? {};
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const layout = new LayoutBuilder();
|
|
439
|
+
try {
|
|
440
|
+
await actionRef.handler({
|
|
441
|
+
ctx,
|
|
442
|
+
payload: payload || {}, // Fallback to empty object to prevent crash on destructuring
|
|
443
|
+
id: (typeof payload === "object" && payload !== null && "id" in payload) ? String(payload.id) : String(payload !== undefined && payload !== null ? payload : ""),
|
|
444
|
+
conversation: conversationHelper,
|
|
445
|
+
ui: uiHelper,
|
|
446
|
+
layout,
|
|
447
|
+
navigate,
|
|
448
|
+
});
|
|
449
|
+
// If action populated the layout, render it
|
|
450
|
+
if (layout._elements.length > 0) {
|
|
451
|
+
const { text, keyboard, parseMode, imageUrl } = await buildKeyboard(actionRef.id, // Use the actual action ID so stable IDs can be re-resolved
|
|
452
|
+
layout, ctx, ctx.chat.id, config.translator, undefined, // backToMenuId
|
|
453
|
+
payload);
|
|
454
|
+
const messageToEdit = conversation.session?.__Grambot_last_msg_id ?? ctx.callbackQuery?.message?.message_id;
|
|
455
|
+
const isPhotoMessage = !!ctx.callbackQuery?.message && "photo" in ctx.callbackQuery.message;
|
|
456
|
+
await internalRenderLayout(ctx.chat.id, text, keyboard, parseMode, imageUrl, ctx, true, // try to edit if possible
|
|
457
|
+
messageToEdit, isPhotoMessage, undefined);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
catch (e) {
|
|
461
|
+
const err = e;
|
|
462
|
+
if (err.message === "Grambot_EXTERNAL") {
|
|
463
|
+
return; // Exit silently, let next middleware take over
|
|
464
|
+
}
|
|
465
|
+
throw e;
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
const convId = `Grambot_${actionId}`;
|
|
469
|
+
try {
|
|
470
|
+
bot.use(createConversation(convBuilder, { id: convId }));
|
|
471
|
+
}
|
|
472
|
+
catch (e) {
|
|
473
|
+
console.error(`Grambot Error: Failed to register conversation ${convId}`, e);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// 4. Pre-check logic for Actions to trigger Conversation Middleware
|
|
477
|
+
// This middleware intercept `act:` callbacks and manually flags the conversation
|
|
478
|
+
// so that when the specific `createConversation` middleware runs next, it picks it up immediately.
|
|
479
|
+
bot.use(async (ctx, next) => {
|
|
480
|
+
if (ctx.callbackQuery?.data && ctx.callbackQuery.data.startsWith("a:")) {
|
|
481
|
+
const data = ctx.callbackQuery.data;
|
|
482
|
+
const parts = data.split("/");
|
|
483
|
+
let actionId;
|
|
484
|
+
let originMenuId;
|
|
485
|
+
if (parts.length > 1) {
|
|
486
|
+
const left = parts[0];
|
|
487
|
+
actionId = left.slice(2); // remove "a:"
|
|
488
|
+
const right = parts[1];
|
|
489
|
+
const colonIdx = right.indexOf(":");
|
|
490
|
+
if (colonIdx !== -1) {
|
|
491
|
+
originMenuId = right.slice(0, colonIdx);
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
originMenuId = right;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
actionId = data.slice(2);
|
|
499
|
+
}
|
|
500
|
+
if (originMenuId === "" || originMenuId === "undefined")
|
|
501
|
+
originMenuId = undefined;
|
|
502
|
+
if (ctx.session)
|
|
503
|
+
ctx.session.originMenuId = originMenuId;
|
|
504
|
+
const conversationId = `Grambot_${actionId}`;
|
|
505
|
+
try {
|
|
506
|
+
await ctx.conversation.enter(conversationId);
|
|
507
|
+
}
|
|
508
|
+
catch (e) {
|
|
509
|
+
console.error(`Grambot Error: Failed to enter conversation ${conversationId}:`, e);
|
|
510
|
+
await ctx.answerCallbackQuery("Action prevented. See logs.");
|
|
511
|
+
await renderMenu(rootRef.id, ctx, ctx.chat.id);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
await next();
|
|
516
|
+
});
|
|
517
|
+
// 5. Register Text Triggers (Commands, Words, Regexps)
|
|
518
|
+
// Helper to enter action conversation
|
|
519
|
+
const enterAction = async (ctx, actionId, payload) => {
|
|
520
|
+
if (ctx.session) {
|
|
521
|
+
ctx.session.originMenuId = undefined; // Triggered via text, no origin menu
|
|
522
|
+
// Store payload in session temporarily so the conversation builder can access it
|
|
523
|
+
// @ts-ignore: custom property for internal use
|
|
524
|
+
ctx.session.__Grambot_payload = payload;
|
|
525
|
+
}
|
|
526
|
+
await ctx.conversation.enter(`Grambot_${actionId}`);
|
|
527
|
+
return true; // Mark as handled
|
|
528
|
+
};
|
|
529
|
+
// Actions
|
|
530
|
+
for (const [actionId, actionRef] of actions) {
|
|
531
|
+
if (!actionRef.triggers)
|
|
532
|
+
continue;
|
|
533
|
+
const { commands, words, regexps } = actionRef.triggers;
|
|
534
|
+
if (commands) {
|
|
535
|
+
for (const cmd of commands) {
|
|
536
|
+
bot.command(cmd, async (ctx) => { if (await enterAction(ctx, actionId))
|
|
537
|
+
return; });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (words) {
|
|
541
|
+
for (const word of words) {
|
|
542
|
+
bot.hears(word, async (ctx) => { if (await enterAction(ctx, actionId))
|
|
543
|
+
return; });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (regexps) {
|
|
547
|
+
for (const re of regexps) {
|
|
548
|
+
bot.hears(re, async (ctx) => {
|
|
549
|
+
const match = ctx.match;
|
|
550
|
+
let payload = undefined;
|
|
551
|
+
if (match && match.length > 1) {
|
|
552
|
+
payload = { id: parseInt(match[1], 10) || match[1] };
|
|
553
|
+
}
|
|
554
|
+
if (await enterAction(ctx, actionId, payload))
|
|
555
|
+
return;
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// Menus
|
|
561
|
+
for (const [menuId, collected] of menus) {
|
|
562
|
+
if (!collected.ref.triggers)
|
|
563
|
+
continue;
|
|
564
|
+
const { commands, words, regexps } = collected.ref.triggers;
|
|
565
|
+
const showMenu = async (ctx) => {
|
|
566
|
+
await renderMenu(menuId, ctx, ctx.chat.id);
|
|
567
|
+
};
|
|
568
|
+
if (commands) {
|
|
569
|
+
for (const cmd of commands) {
|
|
570
|
+
bot.command(cmd, (ctx) => showMenu(ctx));
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (words) {
|
|
574
|
+
for (const word of words) {
|
|
575
|
+
bot.hears(word, (ctx) => showMenu(ctx));
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (regexps) {
|
|
579
|
+
for (const re of regexps) {
|
|
580
|
+
bot.hears(re, (ctx) => showMenu(ctx));
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// 6. Generic Menu Renderer Logic (Navigation, Pagination, Tabs, Refresh)
|
|
585
|
+
async function renderMenu(menuId, ctx, chatId, edit = false, prebuiltLayout, targetMessageId) {
|
|
586
|
+
const menu = menus.get(menuId);
|
|
587
|
+
if (!menu) {
|
|
588
|
+
if (edit) {
|
|
589
|
+
try {
|
|
590
|
+
await ctx.deleteMessage();
|
|
591
|
+
}
|
|
592
|
+
catch { }
|
|
593
|
+
}
|
|
594
|
+
await renderMenu(rootRef.id, ctx, chatId, false); // Fallback to root
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const freshLayout = prebuiltLayout ?? new LayoutBuilder();
|
|
598
|
+
if (!prebuiltLayout) {
|
|
599
|
+
const builder = menu.ref.builder;
|
|
600
|
+
await builder(freshLayout, ctx);
|
|
601
|
+
}
|
|
602
|
+
const { text, keyboard, parseMode, imageUrl } = await buildKeyboard(menuId, freshLayout, ctx, chatId, config.translator, menu.parent);
|
|
603
|
+
const messageToEdit = targetMessageId ?? ctx.callbackQuery?.message?.message_id;
|
|
604
|
+
const isPhotoMessage = !!ctx.callbackQuery?.message && "photo" in ctx.callbackQuery.message;
|
|
605
|
+
await internalRenderLayout(chatId, text, keyboard, parseMode, imageUrl, ctx, edit, messageToEdit, isPhotoMessage, targetMessageId);
|
|
606
|
+
}
|
|
607
|
+
/** @internal */
|
|
608
|
+
async function internalRenderLayout(chatId, text, keyboard, parseMode, imageUrl, ctx, edit, messageToEdit, isPhotoMessage, targetMessageId) {
|
|
609
|
+
if (edit && messageToEdit) {
|
|
610
|
+
try {
|
|
611
|
+
if (imageUrl) {
|
|
612
|
+
if (isPhotoMessage && !targetMessageId) {
|
|
613
|
+
// Edit existing photo message (only if it was triggered by a callback on that same message)
|
|
614
|
+
await ctx.editMessageMedia({ type: "photo", media: imageUrl, caption: text, parse_mode: parseMode }, { reply_markup: keyboard });
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
// Transition or explicit target: delete and send new (or try editing media if we have ID)
|
|
618
|
+
try {
|
|
619
|
+
await ctx.api.deleteMessage(chatId, messageToEdit);
|
|
620
|
+
}
|
|
621
|
+
catch { }
|
|
622
|
+
await ctx.replyWithPhoto(imageUrl, { caption: text, reply_markup: keyboard, parse_mode: parseMode });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
if (isPhotoMessage && !targetMessageId) {
|
|
627
|
+
// Transition: Photo -> Text (Delete and send new)
|
|
628
|
+
try {
|
|
629
|
+
await ctx.deleteMessage();
|
|
630
|
+
}
|
|
631
|
+
catch { }
|
|
632
|
+
await ctx.reply(text, { reply_markup: keyboard, parse_mode: parseMode });
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
// Regular text edit or explicit target edit
|
|
636
|
+
await ctx.api.editMessageText(chatId, messageToEdit, text, { reply_markup: keyboard, parse_mode: parseMode });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
catch (e) {
|
|
641
|
+
// If edit fails (e.g. message too old or type mismatch), fallback to reply
|
|
642
|
+
if (!targetMessageId) {
|
|
643
|
+
await ctx.reply(text, { reply_markup: keyboard, parse_mode: parseMode });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
if (imageUrl) {
|
|
649
|
+
await ctx.replyWithPhoto(imageUrl, { caption: text, reply_markup: keyboard, parse_mode: parseMode });
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
await ctx.reply(text, { reply_markup: keyboard, parse_mode: parseMode });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// /start command
|
|
657
|
+
bot.command("start", async (ctx) => {
|
|
658
|
+
const active = await ctx.conversation.active();
|
|
659
|
+
if (Object.keys(active).length > 0)
|
|
660
|
+
return;
|
|
661
|
+
if (ctx.session)
|
|
662
|
+
ctx.session.conversation = {};
|
|
663
|
+
const chatId = ctx.chat.id;
|
|
664
|
+
await renderMenu(rootRef.id, ctx, chatId, false);
|
|
665
|
+
});
|
|
666
|
+
bot.on("message", async (ctx) => {
|
|
667
|
+
const active = await ctx.conversation.active();
|
|
668
|
+
if (Object.keys(active).length > 0)
|
|
669
|
+
return;
|
|
670
|
+
if (ctx.chat.type === "private") {
|
|
671
|
+
await renderMenu(rootRef.id, ctx, ctx.chat.id, false);
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
bot.on("callback_query:data", async (ctx) => {
|
|
675
|
+
const data = ctx.callbackQuery.data;
|
|
676
|
+
if (data.startsWith("a:")) {
|
|
677
|
+
const active = await ctx.conversation.active();
|
|
678
|
+
if (Object.keys(active).length > 0)
|
|
679
|
+
return;
|
|
680
|
+
await ctx.answerCallbackQuery();
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (data.startsWith("n:")) {
|
|
684
|
+
const targetMenuId = data.slice(2);
|
|
685
|
+
const chatId = ctx.chat.id;
|
|
686
|
+
await renderMenu(targetMenuId, ctx, chatId, true);
|
|
687
|
+
await ctx.answerCallbackQuery();
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (data.startsWith("p:")) {
|
|
691
|
+
const parts = data.split(":");
|
|
692
|
+
const menuIdStr = parts[1];
|
|
693
|
+
const listIdxStr = parts[2];
|
|
694
|
+
const newPage = parseInt(parts[3], 10);
|
|
695
|
+
const chatId = ctx.chat.id;
|
|
696
|
+
pageState.set(pageKey(chatId, menuIdStr, parseInt(listIdxStr, 10)), newPage);
|
|
697
|
+
await renderMenu(menuIdStr, ctx, chatId, true);
|
|
698
|
+
await ctx.answerCallbackQuery();
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (data.startsWith("r:")) {
|
|
702
|
+
const menuIdStr = data.slice(2);
|
|
703
|
+
const chatId = ctx.chat.id;
|
|
704
|
+
await renderMenu(menuIdStr, ctx, chatId, true);
|
|
705
|
+
await ctx.answerCallbackQuery();
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (data.startsWith("t:")) {
|
|
709
|
+
const parts = data.split(":");
|
|
710
|
+
const menuIdStr = parts[1];
|
|
711
|
+
const tabBtnId = parts.slice(2).join(":");
|
|
712
|
+
const menu = menus.get(menuIdStr);
|
|
713
|
+
if (!menu)
|
|
714
|
+
return;
|
|
715
|
+
const freshLayout = new LayoutBuilder();
|
|
716
|
+
await menu.ref.builder(freshLayout, ctx);
|
|
717
|
+
for (const el of freshLayout._elements) {
|
|
718
|
+
if (el.kind === "button") {
|
|
719
|
+
const cfg = el.builder._config;
|
|
720
|
+
const btnId = cfg.buttonId ?? (typeof cfg.label === "string" ? cfg.label : "");
|
|
721
|
+
if (btnId === tabBtnId) {
|
|
722
|
+
cfg.isDefault = true;
|
|
723
|
+
}
|
|
724
|
+
else if (cfg.isDefault) {
|
|
725
|
+
cfg.isDefault = false;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const chatId = ctx.chat.id;
|
|
730
|
+
await renderMenu(menuIdStr, ctx, chatId, true, freshLayout);
|
|
731
|
+
await ctx.answerCallbackQuery();
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (data.startsWith("ai:")) {
|
|
735
|
+
// Action Inline handler: execute closure by re-running the parent action
|
|
736
|
+
const parts = data.split("/");
|
|
737
|
+
if (parts.length < 2)
|
|
738
|
+
return;
|
|
739
|
+
const left = parts[0].slice(3); // stableId
|
|
740
|
+
const right = parts[1];
|
|
741
|
+
const colonIdx = right.indexOf(":");
|
|
742
|
+
const originActionId = colonIdx !== -1 ? right.slice(0, colonIdx) : right;
|
|
743
|
+
const pStr = colonIdx !== -1 ? right.slice(colonIdx + 1) : "";
|
|
744
|
+
let payload;
|
|
745
|
+
if (pStr) {
|
|
746
|
+
if (pStr.startsWith("{")) {
|
|
747
|
+
try {
|
|
748
|
+
payload = JSON.parse(pStr);
|
|
749
|
+
}
|
|
750
|
+
catch { }
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
payload = { id: isNaN(Number(pStr)) ? pStr : Number(pStr) };
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const action = actions.get(originActionId);
|
|
757
|
+
if (!action) {
|
|
758
|
+
await ctx.answerCallbackQuery("Action expired or not found.");
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
// We need to re-run the action to find the inline closure
|
|
762
|
+
const layout = new LayoutBuilder();
|
|
763
|
+
const idVal = (payload && typeof payload === "object" && "id" in payload) ? String(payload.id) : (payload !== undefined && payload !== null ? String(payload) : "");
|
|
764
|
+
const navigate = async (menu) => {
|
|
765
|
+
await renderMenu(menu?.id || rootRef.id, ctx, ctx.chat.id, true);
|
|
766
|
+
};
|
|
767
|
+
const mockConv = {
|
|
768
|
+
session: ctx.session,
|
|
769
|
+
external: (fn) => fn(ctx),
|
|
770
|
+
};
|
|
771
|
+
const conversationHelper = createConversationHelper(mockConv, ctx, config.translator, navigate);
|
|
772
|
+
// Execute parent action purely to find the button
|
|
773
|
+
await action.handler({
|
|
774
|
+
ctx: ctx,
|
|
775
|
+
payload: payload || {},
|
|
776
|
+
id: idVal,
|
|
777
|
+
conversation: conversationHelper,
|
|
778
|
+
ui: createUIHelper(ctx),
|
|
779
|
+
layout,
|
|
780
|
+
navigate,
|
|
781
|
+
});
|
|
782
|
+
// Find the button and run its handler
|
|
783
|
+
let btn;
|
|
784
|
+
let bidx = 0;
|
|
785
|
+
for (const el of layout._elements) {
|
|
786
|
+
if (el.kind === "button") {
|
|
787
|
+
const sid = resolveStableId(originActionId, el.builder._config, bidx);
|
|
788
|
+
if (sid === left) {
|
|
789
|
+
btn = el.builder;
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
bidx++;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (btn && btn._config.inlineHandler) {
|
|
796
|
+
await ctx.answerCallbackQuery();
|
|
797
|
+
const subLayout = new LayoutBuilder();
|
|
798
|
+
await btn._config.inlineHandler({
|
|
799
|
+
ctx: ctx,
|
|
800
|
+
payload: payload || {}, // inherit payload
|
|
801
|
+
id: idVal,
|
|
802
|
+
conversation: conversationHelper,
|
|
803
|
+
ui: createUIHelper(ctx),
|
|
804
|
+
layout: subLayout,
|
|
805
|
+
navigate,
|
|
806
|
+
});
|
|
807
|
+
// If the sub-action also populated a layout, render it!
|
|
808
|
+
if (subLayout._elements.length > 0) {
|
|
809
|
+
const { text, keyboard, parseMode, imageUrl } = await buildKeyboard(originActionId, subLayout, ctx, ctx.chat.id, config.translator, undefined, // backToMenuId
|
|
810
|
+
payload);
|
|
811
|
+
const messageToEdit = ctx.callbackQuery?.message?.message_id;
|
|
812
|
+
const isPhotoMessage = !!ctx.callbackQuery?.message && "photo" in ctx.callbackQuery.message;
|
|
813
|
+
await internalRenderLayout(ctx.chat.id, text, keyboard, parseMode, imageUrl, ctx, true, messageToEdit, isPhotoMessage, undefined);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
await ctx.answerCallbackQuery();
|
|
818
|
+
}
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (data.startsWith("_:")) {
|
|
822
|
+
await ctx.answerCallbackQuery();
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
await ctx.answerCallbackQuery();
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Sends a specific menu to a chat programmatically.
|
|
830
|
+
*
|
|
831
|
+
* @param bot - The grammy bot instance.
|
|
832
|
+
* @param chatId - Target chat ID.
|
|
833
|
+
* @param menuRef - The menu to send.
|
|
834
|
+
* @param ctx - Current Grambot context.
|
|
835
|
+
*/
|
|
836
|
+
export async function sendMenu(bot, chatId, menuRef, ctx, translator) {
|
|
837
|
+
const layout = new LayoutBuilder();
|
|
838
|
+
await menuRef.builder(layout, ctx);
|
|
839
|
+
const { text, keyboard, parseMode, imageUrl } = await buildKeyboard(menuRef.id, layout, ctx, chatId, translator, undefined);
|
|
840
|
+
if (imageUrl) {
|
|
841
|
+
await bot.api.sendPhoto(chatId, imageUrl, { caption: text, reply_markup: keyboard, parse_mode: parseMode });
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
await bot.api.sendMessage(chatId, text, { reply_markup: keyboard, parse_mode: parseMode });
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
//# sourceMappingURL=engine.js.map
|