@grammy-x/conversations 0.2.0 → 2.0.1

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/dist/index.js ADDED
@@ -0,0 +1,423 @@
1
+ // src/question-helper.ts
2
+ import { Context, InlineKeyboard, Keyboard } from "grammy";
3
+ import { smartReply, GlobalMenuRegistry } from "@grammy-x/core";
4
+ function randomInteger(minimum, maximum) {
5
+ return Math.floor(Math.random() * (maximum - minimum + 1) + minimum);
6
+ }
7
+ var QuestionHelper = class {
8
+ conversation;
9
+ config;
10
+ ctx;
11
+ message_id;
12
+ constructor(conversation, ctx, config) {
13
+ this.conversation = conversation;
14
+ this.config = config ?? {};
15
+ this.ctx = ctx;
16
+ }
17
+ updateCtx = (ctx) => {
18
+ Object.keys(this.ctx).forEach((key) => delete this.ctx[key]);
19
+ Object.assign(this.ctx, ctx);
20
+ };
21
+ delete = async () => {
22
+ if (this.message_id) {
23
+ await this.ctx.api.deleteMessage(this.ctx.from.id, this.message_id);
24
+ }
25
+ };
26
+ back = async () => {
27
+ const gx = this.ctx.session?._gx;
28
+ if (gx?.history.length > 0) {
29
+ gx.history.pop();
30
+ }
31
+ const targetId = gx?.history.length > 0 ? gx.history.pop() : void 0;
32
+ if (targetId) {
33
+ const menu = GlobalMenuRegistry.get(targetId);
34
+ if (menu) return menu.send(this.ctx);
35
+ }
36
+ };
37
+ reply = async (text, options) => {
38
+ let newText = text;
39
+ const mergedOptions = { ...this.config, ...options };
40
+ if (mergedOptions.autoBold !== false) newText = `<b>${newText}</b>`;
41
+ if (options?.markup instanceof InlineKeyboard) mergedOptions.markup = options.markup;
42
+ if (!mergedOptions.markup) mergedOptions.markup = new InlineKeyboard();
43
+ if (mergedOptions.markup instanceof InlineKeyboard && this.config.markup instanceof InlineKeyboard)
44
+ mergedOptions.markup.append(this.config.markup);
45
+ if (mergedOptions.fastMenu && mergedOptions.markup instanceof InlineKeyboard) {
46
+ const markup = mergedOptions.markup;
47
+ markup.text(mergedOptions.fastMenuText ?? this.config?.fastMenuText ?? "Main Menu", mergedOptions.fastMenuCallbackData ?? "start");
48
+ }
49
+ if (options?.sendContinueButton && mergedOptions.continueInlineEnd && mergedOptions.markup instanceof InlineKeyboard) {
50
+ const markup = mergedOptions.markup;
51
+ markup.text(options?.continueButton ?? this.config?.continueButton ?? "Continue", "continue");
52
+ }
53
+ const message = await smartReply(this.ctx, text, {
54
+ options: {
55
+ entities: mergedOptions.entities,
56
+ reply_markup: mergedOptions.markup
57
+ },
58
+ messageToEdit: this.message_id,
59
+ newMessage: mergedOptions?.newMessage,
60
+ embolden: false,
61
+ dedent: false
62
+ });
63
+ this.message_id = message?.message_id ?? this.message_id;
64
+ return message;
65
+ };
66
+ getCallbackDataFromKeyboard = (keyboard) => {
67
+ if (keyboard && keyboard instanceof InlineKeyboard)
68
+ return keyboard.inline_keyboard.flat().map((b) => b.callback_data).filter(Boolean);
69
+ return [];
70
+ };
71
+ textParser = async (text, parser, validator, options) => {
72
+ const message = await this.reply(text, options);
73
+ const additionalTriggers = this.getCallbackDataFromKeyboard(options?.markup);
74
+ const answer = await this.conversation.waitUntil(
75
+ (ctx) => Context.has.callbackQuery(additionalTriggers)(ctx) || ctx.has(":text")
76
+ );
77
+ this.updateCtx(answer);
78
+ const callbackQuery = answer.callbackQuery?.data;
79
+ if (callbackQuery) return { callbackQuery, answerCtx: answer, message };
80
+ if (answer.message?.text?.startsWith("/")) {
81
+ await this.ctx.api.deleteMessage(this.ctx.from.id, answer.message.message_id).catch(() => {
82
+ });
83
+ const cmd = answer.message.text.split(" ")[0];
84
+ const err = new Error(`CommandInterrupt:${cmd}`);
85
+ err.name = "CommandInterruptError";
86
+ err.ctx = answer;
87
+ throw err;
88
+ }
89
+ const result = parser(answer.msg.text);
90
+ await this.ctx.api.deleteMessage(this.ctx.from.id, answer.msg.message_id).catch(() => {
91
+ });
92
+ if (!validator(result)) return await this.conversation.skip();
93
+ return { result, answerCtx: answer, message };
94
+ };
95
+ text = (text, options) => this.textParser(
96
+ text,
97
+ (r) => r,
98
+ () => true,
99
+ options
100
+ );
101
+ int = (text, options) => this.textParser(text, parseInt, (r) => !isNaN(r), options);
102
+ float = (text, options) => this.textParser(text, parseFloat, (r) => !isNaN(r), options);
103
+ chat = async (text, options) => {
104
+ const chat = options.chat;
105
+ const markup = new Keyboard().requestChat("\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u0447\u0430\u0442 \u{1F50D}", randomInteger(0, 999999), {
106
+ chat_is_channel: chat.isChannel,
107
+ bot_administrator_rights: chat.requiredBotRights,
108
+ bot_is_member: chat.botIsMember,
109
+ request_photo: chat.requestPhoto,
110
+ request_title: chat.requestTitle,
111
+ request_username: chat.requestUsername ?? true,
112
+ user_administrator_rights: chat.requiredUserRights
113
+ }).oneTime(true).resized(true);
114
+ const message = await this.reply(text, { ...options, markup });
115
+ const answer = await this.conversation.waitFor(":chat_shared");
116
+ this.updateCtx(answer);
117
+ await answer?.msg?.delete?.().catch?.(() => {
118
+ });
119
+ return answer.msg.chat_shared;
120
+ };
121
+ user = async (text, options) => {
122
+ const user = options?.user;
123
+ const markup = new Keyboard().requestUsers("\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F \u{1F50D}", randomInteger(0, 999999), {
124
+ max_quantity: 1,
125
+ request_name: user?.requestName,
126
+ request_photo: user?.requestPhoto,
127
+ request_username: user?.requestUsername ?? true,
128
+ user_is_bot: user?.isBot,
129
+ user_is_premium: user?.isPremium
130
+ }).oneTime(true).resized(true);
131
+ const message = await this.reply(text, { ...options, markup });
132
+ const answer = await this.conversation.waitFor(":users_shared");
133
+ this.updateCtx(answer);
134
+ await answer?.msg?.delete?.().catch?.(() => {
135
+ });
136
+ return answer.msg.users_shared?.users[0];
137
+ };
138
+ users = async (text, options) => {
139
+ const users = options?.users;
140
+ const markup = new Keyboard().requestUsers("\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u0435\u0439 \u{1F50D}", randomInteger(0, 999999), {
141
+ max_quantity: users?.maxQuantity,
142
+ request_name: users?.requestName,
143
+ request_photo: users?.requestPhoto,
144
+ request_username: users?.requestUsername ?? true,
145
+ user_is_bot: users?.isBot,
146
+ user_is_premium: users?.isPremium
147
+ }).oneTime(true).resized(true);
148
+ const message = await this.reply(text, { ...options, markup });
149
+ const answer = await this.conversation.waitFor(":users_shared");
150
+ this.updateCtx(answer);
151
+ await answer?.msg?.delete?.().catch?.(() => {
152
+ });
153
+ return answer.msg.users_shared;
154
+ };
155
+ contact = async (text, options) => {
156
+ const markup = new Keyboard().requestContact("\u041F\u043E\u0434\u0435\u043B\u0438\u0442\u044C\u0441\u044F \u043A\u043E\u043D\u0442\u0430\u043A\u0442\u043E\u043C \u{1F4DE}").oneTime(true).resized(true);
157
+ const message = await this.reply(text, { ...options, markup });
158
+ const answer = await this.conversation.waitFor(":contact");
159
+ this.updateCtx(answer);
160
+ await answer?.msg?.delete?.().catch?.(() => {
161
+ });
162
+ return answer.msg.contact;
163
+ };
164
+ location = async (text, options) => {
165
+ const markup = new Keyboard().requestLocation("\u041F\u043E\u0434\u0435\u043B\u0438\u0442\u044C\u0441\u044F \u0433\u0435\u043E\u043F\u043E\u0437\u0438\u0446\u0438\u0435\u0439 \u{1F4CD}").oneTime(true).resized(true);
166
+ const message = await this.reply(text, { ...options, markup });
167
+ const answer = await this.conversation.waitFor(":location");
168
+ this.updateCtx(answer);
169
+ await answer?.msg?.delete?.().catch?.(() => {
170
+ });
171
+ return answer.msg.location;
172
+ };
173
+ poll = async (text, options) => {
174
+ const pollType = options?.poll?.type;
175
+ const markup = new Keyboard().requestPoll("\u041E\u0442\u043F\u0440\u0430\u0432\u0438\u0442\u044C \u043E\u043F\u0440\u043E\u0441 \u{1F4CA}", pollType).oneTime(true).resized(true);
176
+ const message = await this.reply(text, { ...options, markup });
177
+ const answer = await this.conversation.waitFor(":poll");
178
+ this.updateCtx(answer);
179
+ await answer?.msg?.delete?.().catch?.(() => {
180
+ });
181
+ return answer.msg.poll;
182
+ };
183
+ renderPaginationButtons = (pagesCount, currentPage, markup, columnCount, extraControls) => {
184
+ const oddColumnCount = columnCount % 2 === 0 ? columnCount + 1 : columnCount;
185
+ const emptyColumns = oddColumnCount >= 5 ? extraControls ? 0 : 1 : 0;
186
+ for (let i = 0; i < emptyColumns; i++) {
187
+ markup.text(" ", " ");
188
+ }
189
+ if (extraControls) {
190
+ markup.text(currentPage > 2 ? "\u22D8" : " ", "first");
191
+ }
192
+ markup.text(currentPage > 1 ? "\u2190" : " ", "prev");
193
+ markup.text(currentPage + " / " + pagesCount, "page");
194
+ markup.text(currentPage < pagesCount ? "\u2192" : " ", "next");
195
+ if (extraControls) {
196
+ markup.text(currentPage < pagesCount - 1 ? "\u22D9" : " ", "last");
197
+ }
198
+ for (let i = 0; i < emptyColumns; i++) {
199
+ markup.text(" ", " ");
200
+ }
201
+ return markup.row();
202
+ };
203
+ resetChoice = (session) => {
204
+ if (!session._gx.conversations) session._gx.conversations = {
205
+ currentChoices: /* @__PURE__ */ new Set(),
206
+ currentPage: 1
207
+ };
208
+ else {
209
+ session._gx.conversations.currentChoices = /* @__PURE__ */ new Set();
210
+ session._gx.conversations.currentPage = 1;
211
+ }
212
+ };
213
+ async basicChoice(text, choices, multi = false, options, inProgress) {
214
+ const markup = new InlineKeyboard();
215
+ const session = await this.conversation.external(
216
+ () => this.ctx.session
217
+ );
218
+ if (!session._gx.conversations) this.resetChoice(session);
219
+ await this.conversation.external(() => !inProgress && this.resetChoice(session));
220
+ let currentRow = [];
221
+ const columnCount = options?.columnCount ?? this.config.columnCount ?? 1;
222
+ const rowCount = options?.rowCount ?? this.config.rowCount ?? 3;
223
+ const filledChoices = options?.pagination?.enabled ? choices.concat(
224
+ Array.from({ length: columnCount * rowCount - choices.length % (columnCount * rowCount) }).map(
225
+ () => [" ", "empty"]
226
+ )
227
+ ) : choices;
228
+ const pagesCount = Math.ceil(choices.length / (columnCount * rowCount));
229
+ const currentPage = session._gx.conversations.currentPage ?? 1;
230
+ let currentRowId = 0;
231
+ for (const [choiceId, choice] of filledChoices.entries()) {
232
+ if (options?.pagination?.enabled && choiceId < (currentPage - 1) * columnCount * rowCount) continue;
233
+ const checked = session._gx.conversations.currentChoices.has(choice[1]);
234
+ currentRow.push([`${choice[0]}${checked ? " \u2705" : ""}`, choice[1]]);
235
+ if (currentRow.length === columnCount) {
236
+ currentRow.forEach(
237
+ ([label, value]) => markup.text(label, value == "empty" ? "empty" : `answer:${value}`)
238
+ );
239
+ markup.row();
240
+ currentRow = [];
241
+ currentRowId++;
242
+ if (options?.pagination?.enabled && (currentRowId === rowCount || choiceId === filledChoices.length - 1)) {
243
+ this.renderPaginationButtons(
244
+ pagesCount,
245
+ currentPage,
246
+ markup,
247
+ columnCount,
248
+ options?.pagination?.extraControls ?? false
249
+ );
250
+ break;
251
+ }
252
+ }
253
+ }
254
+ if (currentRow.length > 0) {
255
+ currentRow.forEach(([label, value]) => {
256
+ markup.text(label, `answer:${value}`);
257
+ });
258
+ markup.row();
259
+ }
260
+ const additionalTriggers = this.getCallbackDataFromKeyboard(options?.markup);
261
+ if (options?.markup instanceof InlineKeyboard) markup.append(options.markup).row();
262
+ const sendContinueButton = session._gx.conversations.currentChoices.size > 0;
263
+ const continueInlineEnd = options?.continueInlineEnd ?? this.config.continueInlineEnd;
264
+ if (multi && !options?.noChoiceAllowed && !continueInlineEnd && sendContinueButton)
265
+ markup.text(options?.continueButton ?? this.config?.continueButton ?? "Continue", "continue").row();
266
+ const messageText = typeof text === "function" ? await text(
267
+ Array.from(session._gx.conversations.currentChoices),
268
+ session._gx.conversations.currentPage,
269
+ pagesCount
270
+ ) : text;
271
+ const message = await this.reply(messageText, {
272
+ ...options,
273
+ markup,
274
+ continueButton: options?.continueButton,
275
+ continueInlineEnd,
276
+ sendContinueButton: continueInlineEnd && sendContinueButton
277
+ });
278
+ let additionalTriggerCalled;
279
+ let answer;
280
+ const paginationsCallbacks = ["prev", "next", "page", "empty", "first", "last"];
281
+ do {
282
+ answer = await this.conversation.waitForCallbackQuery([
283
+ ...choices.map((c) => `answer:${c[1]}`),
284
+ ...multi ? ["continue"] : [],
285
+ ...options?.pagination?.enabled ? paginationsCallbacks : [],
286
+ ...additionalTriggers
287
+ ]);
288
+ this.updateCtx(answer);
289
+ const callbackData2 = answer.callbackQuery.data;
290
+ additionalTriggerCalled = additionalTriggers.includes(callbackData2);
291
+ if (callbackData2 == "continue" || additionalTriggerCalled) {
292
+ const choices2 = Array.from(session._gx.conversations.currentChoices);
293
+ this.ctx.session._gx.conversations.currentChoices = /* @__PURE__ */ new Set();
294
+ if (additionalTriggerCalled) return { result: choices2, callbackQuery: callbackData2, message };
295
+ return { result: choices2, message };
296
+ }
297
+ } while (additionalTriggerCalled);
298
+ const questionData = session._gx.conversations;
299
+ const callbackData = answer.callbackQuery.data;
300
+ const data = callbackData.split(":")[1];
301
+ const skip = await this.conversation.external(async () => {
302
+ if (callbackData == "prev") questionData.currentPage = Math.max(currentPage - 1, 1);
303
+ if (callbackData == "next") questionData.currentPage = Math.min(currentPage + 1, pagesCount);
304
+ if (callbackData == "first") questionData.currentPage = 1;
305
+ if (callbackData == "last") questionData.currentPage = pagesCount;
306
+ if (data) {
307
+ if (questionData.currentChoices.has(data)) questionData.currentChoices.delete(data);
308
+ else questionData.currentChoices.add(data);
309
+ }
310
+ ;
311
+ this.ctx.session._gx.conversations = questionData;
312
+ if (paginationsCallbacks.includes(callbackData) && questionData.currentPage == currentPage) {
313
+ await this.ctx.answerCallbackQuery();
314
+ return true;
315
+ }
316
+ });
317
+ if (skip) return this.conversation.skip({ drop: true });
318
+ if (!multi && data) {
319
+ ;
320
+ this.ctx.session._gx.conversations.currentChoices = /* @__PURE__ */ new Set();
321
+ return {
322
+ result: data,
323
+ message
324
+ };
325
+ }
326
+ return this.basicChoice(text, choices, multi, options, true);
327
+ }
328
+ choice = async (text, choices, options) => this.basicChoice(text, choices, false, options);
329
+ multi = async (text, choices, options) => this.basicChoice(text, choices, true, options);
330
+ boolean = async (text, yesNoStrings, options) => {
331
+ const { result, message } = await this.choice(
332
+ text,
333
+ [
334
+ [yesNoStrings?.[0] ?? "\u2705 \u0414\u0430", "true"],
335
+ [yesNoStrings?.[1] ?? "\u274C \u041D\u0435\u0442", "false"]
336
+ ],
337
+ { columnCount: 2, ...options }
338
+ );
339
+ const boolResult = result === "true";
340
+ return { result: boolResult, message };
341
+ };
342
+ photo = async (text, options) => {
343
+ const message = await this.reply(text, options);
344
+ const answer = await this.conversation.waitFor(":photo");
345
+ this.updateCtx(answer);
346
+ await answer?.msg?.delete?.().catch?.(() => {
347
+ });
348
+ const photo = answer.msg.photo?.[0];
349
+ if (!photo) {
350
+ throw new Error("No photo found in update");
351
+ }
352
+ return photo.file_id;
353
+ };
354
+ file = async (text, options) => {
355
+ const message = await this.reply(text, options);
356
+ const answer = await this.conversation.waitFor(":file");
357
+ this.updateCtx(answer);
358
+ await answer?.msg?.delete?.().catch?.(() => {
359
+ });
360
+ return answer.msg.document?.file_id;
361
+ };
362
+ };
363
+
364
+ // src/conversation.ts
365
+ import { createConversation } from "@grammyjs/conversations";
366
+ import { hydrate } from "@grammyjs/hydrate";
367
+ import { GlobalMenuRegistry as GlobalMenuRegistry2, getGrammyXOptions } from "@grammy-x/core";
368
+ function createCustomConversation(builder, config) {
369
+ let cfg = typeof config === "string" ? { id: config } : config;
370
+ cfg = cfg ?? {};
371
+ const id = cfg.id ?? builder.name;
372
+ cfg.id = id;
373
+ GlobalMenuRegistry2.register({
374
+ menuName: id,
375
+ send: async (ctx) => {
376
+ if (!ctx.conversation) {
377
+ console.error(`[Grammy-X] Cannot enter conversation '${id}': ctx.conversation is undefined. Make sure bot.use(conversations()) is registered before components that use it.`);
378
+ return;
379
+ }
380
+ if (ctx.conversation.active) {
381
+ await ctx.conversation.exit();
382
+ }
383
+ return ctx.conversation.enter(id);
384
+ }
385
+ });
386
+ return createConversation(
387
+ (async (conversation, ctx) => {
388
+ await conversation.run(hydrate());
389
+ const gx = ctx.session?._gx;
390
+ if (gx && gx.history[gx.history.length - 1] !== id) {
391
+ gx.history.push(id);
392
+ if (gx.history.length > 20) gx.history.shift();
393
+ }
394
+ try {
395
+ return await builder(conversation, ctx);
396
+ } catch (e) {
397
+ if (e.name === "CommandInterruptError") {
398
+ const cmd = e.message.split(":")[1];
399
+ const opts = getGrammyXOptions(e.ctx);
400
+ const targetMenu = opts?.interruptCommands?.[cmd];
401
+ if (targetMenu) {
402
+ try {
403
+ if (typeof targetMenu === "string") {
404
+ await e.ctx.menu.nav(targetMenu);
405
+ } else if (typeof targetMenu.send === "function") {
406
+ await targetMenu.send(e.ctx);
407
+ }
408
+ } catch (err) {
409
+ console.error(`Could not auto-nav to target menu for command ${cmd}`, err);
410
+ }
411
+ }
412
+ return;
413
+ }
414
+ throw e;
415
+ }
416
+ }),
417
+ cfg
418
+ );
419
+ }
420
+ export {
421
+ QuestionHelper,
422
+ createCustomConversation
423
+ };
package/package.json CHANGED
@@ -1,19 +1,42 @@
1
1
  {
2
2
  "name": "@grammy-x/conversations",
3
- "version": "0.2.0",
3
+ "version": "2.0.1",
4
+
4
5
  "type": "module",
5
- "main": "src/index.ts",
6
- "types": "src/index.ts",
6
+
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js",
15
+ "require": "./dist/index.cjs"
16
+ }
17
+ },
18
+
7
19
  "private": false,
20
+
8
21
  "publishConfig": {
9
- "access": "restricted"
22
+ "access": "public"
10
23
  },
24
+
11
25
  "dependencies": {
12
- "@grammy-x/core": "0.1.3"
26
+ "@grammy-x/core": "0.2.1"
13
27
  },
28
+
14
29
  "peerDependencies": {
15
30
  "grammy": "^1.24.0",
16
31
  "@grammyjs/conversations": "^1",
17
32
  "@grammyjs/hydrate": "^1.4.1"
33
+ },
34
+
35
+ "files": [
36
+ "dist"
37
+ ],
38
+
39
+ "scripts": {
40
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean"
18
41
  }
19
- }
42
+ }
@@ -1,26 +0,0 @@
1
- import type { ConversationConfig } from "@grammyjs/conversations"
2
- import { createConversation } from "@grammyjs/conversations"
3
- import type { Conversation } from "@grammyjs/conversations"
4
- import { hydrate } from "@grammyjs/hydrate"
5
- import type { Context, MiddlewareFn } from "grammy"
6
-
7
- export type ConversationFn<C extends Context = Context> = (
8
- conversation: Conversation<C>,
9
- ctx: C
10
- ) => unknown | Promise<unknown>
11
-
12
- export function createCustomConversation<C extends Context = Context>(
13
- builder: ConversationFn<C>,
14
- config?: string | ConversationConfig
15
- ): MiddlewareFn<any> {
16
- let cfg = typeof config === "string" ? { id: config } : config
17
- cfg = cfg ?? {}
18
- cfg.id = cfg.id ?? builder.name
19
- return createConversation(
20
- (async (conversation: any, ctx: any) => {
21
- await conversation.run(hydrate())
22
- return builder(conversation, ctx)
23
- }),
24
- cfg
25
- ) as MiddlewareFn<any>
26
- }
package/src/index.ts DELETED
@@ -1,3 +0,0 @@
1
- export { QuestionHelper } from "./question-helper.js"
2
- export { createCustomConversation } from "./conversation.js"
3
- export type { ConversationFn } from "./conversation.js"