@grammy-x/conversations 0.1.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/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@grammy-x/conversations",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "private": false,
8
+ "publishConfig": {
9
+ "access": "restricted"
10
+ },
11
+ "dependencies": {
12
+ "@grammy-x/core": "0.1.0"
13
+ },
14
+ "peerDependencies": {
15
+ "grammy": "^1.24.0",
16
+ "@grammyjs/conversations": "^1",
17
+ "@grammyjs/hydrate": "^1.4.1"
18
+ }
19
+ }
@@ -0,0 +1,27 @@
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 { MiddlewareFn } from "grammy"
6
+ import type { GrammyXFlavor } from "@grammy-x/core"
7
+
8
+ export type ConversationFn<C extends GrammyXFlavor = GrammyXFlavor> = (
9
+ conversation: Conversation<C>,
10
+ ctx: C
11
+ ) => unknown | Promise<unknown>
12
+
13
+ export function createCustomConversation<C extends GrammyXFlavor = GrammyXFlavor>(
14
+ builder: ConversationFn<C>,
15
+ config?: string | ConversationConfig
16
+ ): MiddlewareFn<any> {
17
+ let cfg = typeof config === "string" ? { id: config } : config
18
+ cfg = cfg ?? {}
19
+ cfg.id = cfg.id ?? builder.name
20
+ return createConversation(
21
+ (async (conversation: any, ctx: any) => {
22
+ await conversation.run(hydrate())
23
+ return builder(conversation, ctx)
24
+ }),
25
+ cfg
26
+ ) as MiddlewareFn<any>
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { QuestionHelper } from "./question-helper.js"
2
+ export { createCustomConversation } from "./conversation.js"
3
+ export type { ConversationFn } from "./conversation.js"
@@ -0,0 +1,453 @@
1
+ import type { Conversation } from "@grammyjs/conversations"
2
+ import { CallbackQueryContext, Context, InlineKeyboard, Keyboard } from "grammy"
3
+ import type { ChatAdministratorRights, ChatShared } from "grammy/types"
4
+ import { smartReply } from "@grammy-x/core"
5
+ import type { GrammyXFlavor } from "@grammy-x/core"
6
+
7
+ type MaybePromise<T> = PromiseLike<T> | T
8
+ type ButtonsMarkup = InlineKeyboard | Keyboard
9
+
10
+ interface QuestionParameters {
11
+ markup?: ButtonsMarkup
12
+ autoBold?: boolean
13
+ entities?: any[]
14
+ fastMenu?: boolean
15
+ fastMenuCallbackData?: string
16
+ fastMenuText?: string
17
+ newMessage?: boolean
18
+ }
19
+
20
+ interface QuestionCallbackParameters extends QuestionParameters {
21
+ columnCount?: number
22
+ noChoiceAllowed?: boolean
23
+ rowCount?: number
24
+ continueButton?: string
25
+ pagination?: {
26
+ enabled: boolean
27
+ extraControls?: boolean
28
+ }
29
+ }
30
+
31
+ interface QuestionMultiParameters extends QuestionCallbackParameters {
32
+ continueInlineEnd?: boolean
33
+ }
34
+
35
+ interface QuestionChatParameters extends QuestionParameters {
36
+ chat: {
37
+ requiredUserRights?: ChatAdministratorRights
38
+ requiredBotRights?: ChatAdministratorRights
39
+ botIsMember?: boolean
40
+ isChannel: boolean
41
+ }
42
+ }
43
+
44
+ type QuestionUniversalParameters = Partial<QuestionMultiParameters & QuestionChatParameters>
45
+
46
+ interface QuestionInternalParameters extends QuestionUniversalParameters {
47
+ continueButton?: string
48
+ sendContinueButton?: boolean
49
+ }
50
+
51
+ export interface TextParserResult<R> {
52
+ result?: R
53
+ callbackQuery?: string
54
+ answerCtx?: any
55
+ message?: any
56
+ }
57
+
58
+ interface QuestionHelperSession {
59
+ questionHelper: {
60
+ currentChoices: Set<string>
61
+ currentPage: number
62
+ }
63
+ }
64
+
65
+ function randomInteger(minimum: number, maximum: number): number {
66
+ return Math.floor(Math.random() * (maximum - minimum + 1) + minimum)
67
+ }
68
+
69
+ export class QuestionHelper<
70
+ C extends GrammyXFlavor = GrammyXFlavor,
71
+ T extends Conversation<C> = Conversation<C>,
72
+ > {
73
+ private conversation: T
74
+ private config: QuestionUniversalParameters
75
+ private ctx: C
76
+ public message_id?: number
77
+
78
+ constructor(conversation: T, ctx: C, config?: QuestionUniversalParameters) {
79
+ this.conversation = conversation
80
+ this.config = config ?? {}
81
+ this.ctx = ctx
82
+ }
83
+
84
+ private updateCtx = <U extends GrammyXFlavor>(ctx: U) => {
85
+ Object.keys(this.ctx).forEach((key) => delete (this.ctx as any)[key])
86
+ Object.assign(this.ctx, ctx)
87
+ }
88
+
89
+ delete = async () => {
90
+ if (this.message_id) {
91
+ await this.ctx.api.deleteMessage(this.ctx.from!.id, this.message_id)
92
+ }
93
+ }
94
+
95
+ private reply = async (text: string, options?: QuestionInternalParameters) => {
96
+ let newText = text
97
+ const mergedOptions = { ...this.config, ...options }
98
+
99
+ if (mergedOptions.autoBold !== false) newText = `<b>${newText}</b>`
100
+
101
+ if (options?.markup instanceof InlineKeyboard) mergedOptions.markup = options.markup
102
+
103
+ if (!mergedOptions.markup) mergedOptions.markup = new InlineKeyboard()
104
+
105
+ if (mergedOptions.markup instanceof InlineKeyboard && this.config.markup instanceof InlineKeyboard)
106
+ mergedOptions.markup.append(this.config.markup)
107
+
108
+ if (mergedOptions.fastMenu && mergedOptions.markup instanceof InlineKeyboard) {
109
+ const markup = mergedOptions.markup
110
+ markup.text(mergedOptions.fastMenuText ?? "↩️ В главное меню", mergedOptions.fastMenuCallbackData ?? "start")
111
+ }
112
+ if (
113
+ options?.sendContinueButton &&
114
+ mergedOptions.continueInlineEnd &&
115
+ mergedOptions.markup instanceof InlineKeyboard
116
+ ) {
117
+ const markup = mergedOptions.markup
118
+ markup.text(options?.continueButton ?? "➡️ Продолжить", "continue")
119
+ }
120
+
121
+ const message = await smartReply(this.ctx, text, {
122
+ options: {
123
+ entities: mergedOptions.entities,
124
+ reply_markup: mergedOptions.markup as any,
125
+ },
126
+ messageToEdit: this.message_id,
127
+ newMessage: mergedOptions?.newMessage,
128
+ embolden: false,
129
+ dedent: false,
130
+ })
131
+ this.message_id = message?.message_id ?? this.message_id
132
+ return message
133
+ }
134
+
135
+ private getCallbackDataFromKeyboard = (keyboard: ButtonsMarkup | undefined): string[] => {
136
+ if (keyboard && keyboard instanceof InlineKeyboard)
137
+ return keyboard.inline_keyboard
138
+ .flat()
139
+ .map((b: any) => b.callback_data)
140
+ .filter(Boolean)
141
+ return []
142
+ }
143
+
144
+ private textParser = async <R>(
145
+ text: string,
146
+ parser: (data: string) => R,
147
+ validator: (result: R) => boolean,
148
+ options?: QuestionParameters
149
+ ) => {
150
+ const message = await this.reply(text, options)
151
+ const additionalTriggers = this.getCallbackDataFromKeyboard(options?.markup)
152
+
153
+ const answer = await this.conversation.waitUntil(
154
+ (ctx) => Context.has.callbackQuery(additionalTriggers)(ctx) || ctx.has(":text")
155
+ )
156
+
157
+ this.updateCtx(answer)
158
+
159
+ const callbackQuery = answer.callbackQuery?.data
160
+ if (callbackQuery) return { callbackQuery, answerCtx: answer, message }
161
+
162
+ const result = parser((answer as any).msg!.text) as NonNullable<R>
163
+ await this.ctx.api.deleteMessage(this.ctx.from!.id, (answer as any).msg!.message_id).catch(() => {})
164
+ if (!validator(result)) return await this.conversation.skip()
165
+ return { result, answerCtx: answer, message }
166
+ }
167
+
168
+ text = (text: string, options?: QuestionParameters): Promise<TextParserResult<string>> =>
169
+ this.textParser(
170
+ text,
171
+ (r) => r,
172
+ () => true,
173
+ options
174
+ ) as Promise<TextParserResult<string>>
175
+
176
+ int = (text: string, options?: QuestionParameters): Promise<TextParserResult<number>> =>
177
+ this.textParser(text, parseInt, (r) => !isNaN(r), options) as Promise<TextParserResult<number>>
178
+
179
+ float = (text: string, options?: QuestionParameters): Promise<TextParserResult<number>> =>
180
+ this.textParser(text, parseFloat, (r) => !isNaN(r), options) as Promise<TextParserResult<number>>
181
+
182
+ chat = async (text: string, options: QuestionChatParameters) => {
183
+ const chat = options.chat
184
+ const markup = new Keyboard()
185
+ .requestChat("Выбрать чат 🔍", randomInteger(0, 999999), {
186
+ chat_is_channel: chat.isChannel,
187
+ bot_administrator_rights: chat.requiredBotRights,
188
+ bot_is_member: chat.botIsMember,
189
+ request_username: true,
190
+ user_administrator_rights: chat.requiredUserRights,
191
+ })
192
+ .oneTime(true)
193
+ .resized(true)
194
+ const message = await this.reply(text, { ...options, markup })
195
+ const answer = await this.conversation.waitFor(":chat_shared")
196
+
197
+ this.updateCtx(answer)
198
+
199
+ await (answer?.msg as any)?.delete?.().catch?.(() => {})
200
+ return answer.msg.chat_shared as ChatShared
201
+ }
202
+
203
+ private renderPaginationButtons = (
204
+ pagesCount: number,
205
+ currentPage: number,
206
+ markup: InlineKeyboard,
207
+ columnCount: number,
208
+ extraControls: boolean
209
+ ) => {
210
+ const oddColumnCount = columnCount % 2 === 0 ? columnCount + 1 : columnCount
211
+ const emptyColumns = oddColumnCount >= 5 ? (extraControls ? 0 : 1) : 0
212
+ for (let i = 0; i < emptyColumns; i++) {
213
+ markup.text(" ", " ")
214
+ }
215
+ if (extraControls) {
216
+ markup.text(currentPage > 2 ? "⋘" : " ", "first")
217
+ }
218
+ markup.text(currentPage > 1 ? "←" : " ", "prev")
219
+ markup.text(currentPage + " / " + pagesCount, "page")
220
+ markup.text(currentPage < pagesCount ? "→" : " ", "next")
221
+ if (extraControls) {
222
+ markup.text(currentPage < pagesCount - 1 ? "⋙" : " ", "last")
223
+ }
224
+ for (let i = 0; i < emptyColumns; i++) {
225
+ markup.text(" ", " ")
226
+ }
227
+ return markup.row()
228
+ }
229
+
230
+ private resetChoice = (session: QuestionHelperSession) => {
231
+ session.questionHelper = {
232
+ currentChoices: new Set(),
233
+ currentPage: 1,
234
+ }
235
+ }
236
+
237
+ private async basicChoice<const R extends string>(
238
+ text: string,
239
+ choices: [string, R][],
240
+ multi: false,
241
+ options?: QuestionParameters,
242
+ inProgress?: boolean
243
+ ): Promise<{ result: R; message: any }>
244
+ private async basicChoice<const R extends string>(
245
+ text: string | ((choices: R[], currentPage: number, pagesCount: number) => MaybePromise<string>),
246
+ choices: [string, R][],
247
+ multi: true,
248
+ options?: QuestionParameters,
249
+ inProgress?: boolean
250
+ ): Promise<{
251
+ result: R[]
252
+ callbackQuery?: string
253
+ message: any
254
+ }>
255
+ private async basicChoice<const R extends string>(
256
+ text: string | ((choices: R[], currentPage: number, pagesCount: number) => MaybePromise<string>),
257
+ choices: [string, R][],
258
+ multi: boolean = false,
259
+ options?: QuestionMultiParameters,
260
+ inProgress?: boolean
261
+ ): Promise<
262
+ | {
263
+ result: R[]
264
+ callbackQuery?: string
265
+ message: any
266
+ }
267
+ | {
268
+ result: R
269
+ message: any
270
+ }
271
+ > {
272
+ const markup = new InlineKeyboard()
273
+ const session = (await this.conversation.external(
274
+ () => this.ctx.session
275
+ )) as unknown as QuestionHelperSession
276
+ if (!session.questionHelper) this.resetChoice(session)
277
+ await this.conversation.external(() => !inProgress && this.resetChoice(session))
278
+ let currentRow: [string, string][] = []
279
+ const columnCount = options?.columnCount ?? this.config.columnCount ?? 1
280
+ const rowCount = options?.rowCount ?? this.config.rowCount ?? 3
281
+ const filledChoices = options?.pagination?.enabled
282
+ ? choices.concat(
283
+ Array.from({ length: columnCount * rowCount - (choices.length % (columnCount * rowCount)) }).map(
284
+ () => [" ", "empty" as unknown as R]
285
+ )
286
+ )
287
+ : choices
288
+ const pagesCount = Math.ceil(choices.length / (columnCount * rowCount))
289
+ const currentPage = session.questionHelper.currentPage ?? 1
290
+
291
+ let currentRowId = 0
292
+ for (const [choiceId, choice] of filledChoices.entries()) {
293
+ if (options?.pagination?.enabled && choiceId < (currentPage - 1) * columnCount * rowCount) continue
294
+ const checked = session.questionHelper.currentChoices.has(choice[1])
295
+ currentRow.push([`${choice[0]}${checked ? " ✅" : ""}`, choice[1]])
296
+
297
+ if (currentRow.length === columnCount) {
298
+ currentRow.forEach(([label, value]) =>
299
+ markup.text(label, value == "empty" ? "empty" : `answer:${value}`)
300
+ )
301
+ markup.row()
302
+ currentRow = []
303
+ currentRowId++
304
+ if (
305
+ options?.pagination?.enabled &&
306
+ (currentRowId === rowCount || choiceId === filledChoices.length - 1)
307
+ ) {
308
+ this.renderPaginationButtons(
309
+ pagesCount,
310
+ currentPage,
311
+ markup,
312
+ columnCount,
313
+ options?.pagination?.extraControls ?? false
314
+ )
315
+ break
316
+ }
317
+ }
318
+ }
319
+
320
+ if (currentRow.length > 0) {
321
+ currentRow.forEach(([label, value]) => {
322
+ markup.text(label, `answer:${value}`)
323
+ })
324
+ markup.row()
325
+ }
326
+
327
+ const additionalTriggers = this.getCallbackDataFromKeyboard(options?.markup)
328
+ if (options?.markup instanceof InlineKeyboard) markup.append(options.markup).row()
329
+
330
+ const sendContinueButton = session.questionHelper.currentChoices.size > 0
331
+ const continueInlineEnd = options?.continueInlineEnd ?? (this.config as any).continueInlineEnd
332
+ if (multi && !options?.noChoiceAllowed && !continueInlineEnd && sendContinueButton)
333
+ markup.text(options?.continueButton ?? "➡️ Продолжить", "continue").row()
334
+
335
+ const messageText =
336
+ typeof text === "function"
337
+ ? await text(
338
+ Array.from(session.questionHelper.currentChoices) as R[],
339
+ session.questionHelper.currentPage,
340
+ pagesCount
341
+ )
342
+ : text
343
+ const message = await this.reply(messageText, {
344
+ ...options,
345
+ markup,
346
+ continueButton: options?.continueButton,
347
+ continueInlineEnd,
348
+ sendContinueButton: continueInlineEnd && sendContinueButton,
349
+ })
350
+ let additionalTriggerCalled: boolean
351
+ let answer: CallbackQueryContext<C>
352
+
353
+ const paginationsCallbacks = ["prev", "next", "page", "empty", "first", "last"]
354
+
355
+ do {
356
+ answer = await this.conversation.waitForCallbackQuery([
357
+ ...choices.map((c) => `answer:${c[1]}`),
358
+ ...(multi ? ["continue"] : []),
359
+ ...(options?.pagination?.enabled ? paginationsCallbacks : []),
360
+ ...additionalTriggers,
361
+ ])
362
+ this.updateCtx(answer)
363
+ const callbackData = answer.callbackQuery.data
364
+ additionalTriggerCalled = additionalTriggers.includes(callbackData)
365
+
366
+ if (callbackData == "continue" || additionalTriggerCalled) {
367
+ const choices = Array.from(session.questionHelper.currentChoices)
368
+ ;(this.ctx.session as unknown as QuestionHelperSession).questionHelper.currentChoices = new Set()
369
+ if (additionalTriggerCalled) return { result: choices as R[], callbackQuery: callbackData, message }
370
+ return { result: choices as R[], message }
371
+ }
372
+ } while (additionalTriggerCalled)
373
+
374
+ const questionData = session.questionHelper
375
+ const callbackData = answer.callbackQuery.data
376
+ const data = callbackData.split(":")[1]
377
+ const skip = await this.conversation.external(async () => {
378
+ if (callbackData == "prev") questionData.currentPage = Math.max(currentPage - 1, 1)
379
+ if (callbackData == "next") questionData.currentPage = Math.min(currentPage + 1, pagesCount)
380
+ if (callbackData == "first") questionData.currentPage = 1
381
+ if (callbackData == "last") questionData.currentPage = pagesCount
382
+ if (data) {
383
+ if (questionData.currentChoices!.has(data)) questionData.currentChoices!.delete(data)
384
+ else questionData.currentChoices!.add(data)
385
+ }
386
+ ;(this.ctx.session as unknown as QuestionHelperSession).questionHelper = session.questionHelper =
387
+ questionData
388
+ if (paginationsCallbacks.includes(callbackData) && questionData.currentPage == currentPage) {
389
+ await this.ctx.answerCallbackQuery()
390
+ return true
391
+ }
392
+ })
393
+ if (skip) return this.conversation.skip({ drop: true })
394
+ if (!multi && data) {
395
+ ;(this.ctx.session as unknown as QuestionHelperSession).questionHelper.currentChoices = new Set()
396
+ return {
397
+ result: data as R,
398
+ message,
399
+ }
400
+ }
401
+
402
+ //@ts-expect-error bruh typescript
403
+ return this.basicChoice(text, choices, multi, options, true)
404
+ }
405
+
406
+ choice = async <const R extends string>(
407
+ text: string,
408
+ choices: [string, R][],
409
+ options?: QuestionCallbackParameters
410
+ ): Promise<{
411
+ result: R
412
+ message: any
413
+ }> => this.basicChoice(text, choices, false, options)
414
+
415
+ multi = async <const R extends string>(
416
+ text: string | ((choices: R[], currentPage: number, pagesCount: number) => MaybePromise<string>),
417
+ choices: [string, R][],
418
+ options?: QuestionCallbackParameters
419
+ ): Promise<{
420
+ result: R[]
421
+ callbackData?: string
422
+ message: any
423
+ }> => this.basicChoice(text, choices, true, options)
424
+
425
+ boolean = async (text: string, yesNoStrings?: string[], options?: QuestionCallbackParameters) => {
426
+ const { result, message } = await this.choice(
427
+ text,
428
+ [
429
+ [yesNoStrings?.[0] ?? "✅ Да", "true"],
430
+ [yesNoStrings?.[1] ?? "❌ Нет", "false"],
431
+ ],
432
+ { columnCount: 2, ...options }
433
+ )
434
+ const boolResult = result === "true"
435
+ return { result: boolResult, message }
436
+ }
437
+
438
+ photo = async (text: string, options?: QuestionParameters) => {
439
+ const message = await this.reply(text, options)
440
+ const answer = await this.conversation.waitFor(":photo")
441
+ this.updateCtx(answer)
442
+ await (answer?.msg as any)?.delete?.().catch?.(() => {})
443
+ return answer.msg.photo[0].file_id
444
+ }
445
+
446
+ file = async (text: string, options?: QuestionParameters) => {
447
+ const message = await this.reply(text, options)
448
+ const answer = await this.conversation.waitFor(":file")
449
+ this.updateCtx(answer)
450
+ await (answer?.msg as any)?.delete?.().catch?.(() => {})
451
+ return answer.msg.document?.file_id as string
452
+ }
453
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src"]
8
+ }