diff --git a/apps/bot/locales/ru.ftl b/apps/bot/locales/ru.ftl index 84f8adc..9b8a0b8 100644 --- a/apps/bot/locales/ru.ftl +++ b/apps/bot/locales/ru.ftl @@ -46,6 +46,15 @@ commands-list = support = { -support-contact } +# Кнопки +btn-add-contact = 👤 Добавить контакт +btn-share-bot = 🤝 Поделиться ботом +btn-pro = 👑 Pro доступ +btn-subscribe = 👑 Приобрести Pro +btn-pro-info = ℹ️ Мой Pro доступ +btn-open-app = 📱 Открыть приложение +btn-back = ◀️ Назад + # Приветственные сообщения msg-welcome = @@ -91,6 +100,7 @@ err-banned = 🚫 Ваш аккаунт заблокирован err-with-details = ❌ Произошла ошибка { $error } err-limit-exceeded = 🚫 Слишком много запросов! Подождите немного +err-missing-telegram-id = ❌ Telegram ID не найден # Сообщения о доступе @@ -99,12 +109,15 @@ msg-subscribe = • Разблокирует неограниченное количество заказов msg-subscribe-success = ✅ Платеж успешно обработан! msg-subscribe-error = ❌ Произошла ошибка при обработке платежа +msg-subscription-inactive = 🔴 Pro доступ неактивен +msg-subscription-active = 🟢 Ваш Pro доступ активен msg-subscription-active-until = 👑 Ваш Pro доступ активен до { $date } msg-subscription-active-days = 👑 Осталось дней вашего Pro доступа: { $days } +msg-subscription-active-days-short = Осталось дней: { $days } msg-subscription-expired = Ваш Pro доступ истек. Воспользуйтесь командой /subscribe, чтобы получить неограниченное количество заказов -msg-subscribe-disabled = 🚫 Pro доступ отключен для всех. Ограничения сняты! Наслаждайтесь полным доступом! 🎉 +msg-subscribe-disabled = 🟢 Pro доступ отключен для всех. Ограничения сняты! Наслаждайтесь полным доступом! 🎉 # Информация о лимитах msg-remaining-orders-this-month = 🧾 Доступно заказов в этом месяце: { $count } \ No newline at end of file diff --git a/apps/bot/package.json b/apps/bot/package.json index 1979430..1f0a814 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -17,6 +17,7 @@ "@grammyjs/conversations": "^2.1.0", "@grammyjs/hydrate": "^1.6.0", "@grammyjs/i18n": "^1.1.2", + "@grammyjs/menu": "^1.3.1", "@grammyjs/parse-mode": "^2.2.0", "@grammyjs/ratelimiter": "^1.2.1", "@grammyjs/runner": "^2.0.3", @@ -29,7 +30,7 @@ "grammy": "^1.38.1", "ioredis": "^5.7.0", "pino": "^9.9.0", - "pino-pretty": "^13.1.1", + "pino-pretty": "^13.1.1", "radashi": "catalog:", "tsup": "^8.5.0", "typescript": "catalog:", diff --git a/apps/bot/src/bot/conversations/add-contact.ts b/apps/bot/src/bot/conversations/add-contact.ts index e3f7e3b..d931c3e 100644 --- a/apps/bot/src/bot/conversations/add-contact.ts +++ b/apps/bot/src/bot/conversations/add-contact.ts @@ -1,6 +1,6 @@ /* eslint-disable id-length */ import { type Context } from '@/bot/context'; -import { KEYBOARD_REMOVE, KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards'; +import { KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards'; import { combine } from '@/utils/messages'; import { isValidPhoneNumber, normalizePhoneNumber } from '@/utils/phone'; import { type Conversation } from '@grammyjs/conversations'; @@ -74,9 +74,7 @@ export async function addContact(conversation: Conversation, c await ctx.reply( await conversation.external(({ t }) => t('err-with-details', { error: String(error) })), ); - } finally { - await ctx.reply(await conversation.external(({ t }) => t('commands-list')), KEYBOARD_REMOVE); } - return ctx.reply(await conversation.external(({ t }) => t('err-generic')), KEYBOARD_REMOVE); + return conversation.halt(); } diff --git a/apps/bot/src/bot/features/add-contact.ts b/apps/bot/src/bot/features/add-contact.ts index ac4bf52..00660d7 100644 --- a/apps/bot/src/bot/features/add-contact.ts +++ b/apps/bot/src/bot/features/add-contact.ts @@ -1,3 +1,4 @@ +import { handleAddContact } from '../handlers/add-contact'; import { type Context } from '@/bot/context'; import { logHandle } from '@/bot/helpers/logging'; import { Composer } from 'grammy'; @@ -6,8 +7,6 @@ const composer = new Composer(); const feature = composer.chatType('private'); -feature.command('addcontact', logHandle('command-add-contact'), async (ctx) => { - await ctx.conversation.enter('addContact'); -}); +feature.command('addcontact', logHandle('command-add-contact'), handleAddContact); export { composer as addContact }; diff --git a/apps/bot/src/bot/features/help.ts b/apps/bot/src/bot/features/help.ts index 70c9aad..a8ccc60 100644 --- a/apps/bot/src/bot/features/help.ts +++ b/apps/bot/src/bot/features/help.ts @@ -1,7 +1,6 @@ import { type Context } from '@/bot/context'; import { logHandle } from '@/bot/helpers/logging'; -import { KEYBOARD_REMOVE } from '@/config/keyboards'; -import { combine } from '@/utils/messages'; +import { mainMenu } from '@/config/keyboards'; import { Composer } from 'grammy'; const composer = new Composer(); @@ -9,10 +8,7 @@ const composer = new Composer(); const feature = composer.chatType('private'); feature.command('help', logHandle('command-help'), async (ctx) => { - return ctx.reply(combine(ctx.t('commands-list'), ctx.t('support')), { - ...KEYBOARD_REMOVE, - parse_mode: 'HTML', - }); + return ctx.reply(ctx.t('support'), { reply_markup: mainMenu }); }); export { composer as help }; diff --git a/apps/bot/src/bot/features/pro.ts b/apps/bot/src/bot/features/pro.ts index aacd415..4ce8a5a 100644 --- a/apps/bot/src/bot/features/pro.ts +++ b/apps/bot/src/bot/features/pro.ts @@ -1,39 +1,11 @@ +import { handlePro } from '../handlers/pro'; import { type Context } from '@/bot/context'; import { logHandle } from '@/bot/helpers/logging'; -import { combine } from '@/utils/messages'; -import { SubscriptionsService } from '@repo/graphql/api/subscriptions'; import { Composer } from 'grammy'; const composer = new Composer(); const feature = composer.chatType('private'); -feature.command('pro', logHandle('command-pro'), async (ctx) => { - const telegramId = ctx.from.id; - const subscriptionsService = new SubscriptionsService({ telegramId }); - - const { subscriptionSetting } = await subscriptionsService.getSubscriptionSettings(); - const proEnabled = subscriptionSetting?.proEnabled; - - if (!proEnabled) return ctx.reply(ctx.t('msg-subscribe-disabled')); - - const { hasActiveSubscription, remainingDays, remainingOrdersCount } = - await subscriptionsService.getSubscription({ telegramId }); - - if (hasActiveSubscription && remainingDays > 0) { - return ctx.reply( - combine( - ctx.t('msg-subscription-active-days', { days: remainingDays }), - remainingDays === 0 ? ctx.t('msg-subscription-expired') : '', - ), - ); - } - - return ctx.reply( - combine( - ctx.t('msg-remaining-orders-this-month', { count: remainingOrdersCount }), - remainingOrdersCount === 0 ? ctx.t('msg-subscription-expired') : '', - ), - ); -}); +feature.command('pro', logHandle('command-pro'), handlePro); export { composer as pro }; diff --git a/apps/bot/src/bot/features/share-bot.ts b/apps/bot/src/bot/features/share-bot.ts index d867ce5..74b999b 100644 --- a/apps/bot/src/bot/features/share-bot.ts +++ b/apps/bot/src/bot/features/share-bot.ts @@ -1,15 +1,12 @@ +import { handleShareBot } from '../handlers/share-bot'; import { type Context } from '@/bot/context'; import { logHandle } from '@/bot/helpers/logging'; -import { KEYBOARD_SHARE_BOT } from '@/config/keyboards'; import { Composer } from 'grammy'; const composer = new Composer(); const feature = composer.chatType('private'); -feature.command('sharebot', logHandle('command-share-bot'), async (ctx) => { - await ctx.reply(ctx.t('msg-contact-forward'), { parse_mode: 'HTML' }); - await ctx.reply(ctx.t('msg-share-bot'), { ...KEYBOARD_SHARE_BOT, parse_mode: 'HTML' }); -}); +feature.command('sharebot', logHandle('command-share-bot'), handleShareBot); export { composer as shareBot }; diff --git a/apps/bot/src/bot/features/subscription.ts b/apps/bot/src/bot/features/subscription.ts index 36585ee..545ecb1 100644 --- a/apps/bot/src/bot/features/subscription.ts +++ b/apps/bot/src/bot/features/subscription.ts @@ -1,3 +1,4 @@ +import { handleSubscribe } from '../handlers/subscription'; import { type Context } from '@/bot/context'; import { logHandle } from '@/bot/helpers/logging'; import { logger } from '@/utils/logger'; @@ -8,25 +9,13 @@ const composer = new Composer(); // Telegram требует отвечать на pre_checkout_query composer.on('pre_checkout_query', logHandle('pre-checkout-query'), async (ctx) => { - console.log('🚀 ~ ctx:', ctx); await ctx.answerPreCheckoutQuery(true); }); const feature = composer.chatType('private'); // команда для входа в flow подписки -feature.command('subscribe', logHandle('command-subscribe'), async (ctx) => { - const telegramId = ctx.from.id; - const subscriptionsService = new SubscriptionsService({ telegramId }); - - const { subscriptionSetting } = await subscriptionsService.getSubscriptionSettings(); - - const proEnabled = subscriptionSetting?.proEnabled; - - if (!proEnabled) return ctx.reply(ctx.t('msg-subscribe-disabled')); - - return ctx.conversation.enter('subscription'); -}); +feature.command('subscribe', logHandle('command-subscribe'), handleSubscribe); // успешная оплата feature.on(':successful_payment', logHandle('successful-payment'), async (ctx) => { diff --git a/apps/bot/src/bot/features/welcome.ts b/apps/bot/src/bot/features/welcome.ts index da98597..cd6abcb 100644 --- a/apps/bot/src/bot/features/welcome.ts +++ b/apps/bot/src/bot/features/welcome.ts @@ -1,6 +1,6 @@ import { type Context } from '@/bot/context'; import { logHandle } from '@/bot/helpers/logging'; -import { KEYBOARD_REMOVE, KEYBOARD_SHARE_PHONE } from '@/config/keyboards'; +import { KEYBOARD_SHARE_PHONE, mainMenu } from '@/config/keyboards'; import { combine } from '@/utils/messages'; import { CustomersService } from '@repo/graphql/api/customers'; import { Composer } from 'grammy'; @@ -17,13 +17,9 @@ feature.command('start', logHandle('command-start'), async (ctx) => { if (customer) { // Пользователь уже зарегистрирован — приветствуем - return ctx.reply( - combine(ctx.t('msg-welcome-back', { name: customer.name }), ctx.t('commands-list')), - { - ...KEYBOARD_REMOVE, - parse_mode: 'HTML', - }, - ); + return ctx.reply(combine(ctx.t('msg-welcome-back', { name: customer.name })), { + reply_markup: mainMenu, + }); } // Новый пользователь — просим поделиться номером diff --git a/apps/bot/src/bot/handlers/add-contact.ts b/apps/bot/src/bot/handlers/add-contact.ts new file mode 100644 index 0000000..e6ac72f --- /dev/null +++ b/apps/bot/src/bot/handlers/add-contact.ts @@ -0,0 +1,7 @@ +import { type Context } from '@/bot/context'; + +async function handler(ctx: Context) { + await ctx.conversation.enter('addContact'); +} + +export { handler as handleAddContact }; diff --git a/apps/bot/src/bot/handlers/index.ts b/apps/bot/src/bot/handlers/index.ts new file mode 100644 index 0000000..64abb56 --- /dev/null +++ b/apps/bot/src/bot/handlers/index.ts @@ -0,0 +1,4 @@ +export * from './add-contact'; +export * from './pro'; +export * from './share-bot'; +export * from './subscription'; diff --git a/apps/bot/src/bot/handlers/pro.ts b/apps/bot/src/bot/handlers/pro.ts new file mode 100644 index 0000000..cb113a4 --- /dev/null +++ b/apps/bot/src/bot/handlers/pro.ts @@ -0,0 +1,41 @@ +import { type Context } from '@/bot/context'; +import { combine } from '@/utils/messages'; +import { SubscriptionsService } from '@repo/graphql/api/subscriptions'; + +async function handler(ctx: Context) { + const telegramId = ctx?.from?.id; + + if (!telegramId) throw new Error(ctx.t('err-missing-telegram-id')); + + const subscriptionsService = new SubscriptionsService({ telegramId }); + + const { subscriptionSetting } = await subscriptionsService.getSubscriptionSettings(); + const proEnabled = subscriptionSetting?.proEnabled; + + if (!proEnabled) { + await ctx.reply(ctx.t('msg-subscribe-disabled')); + } + + const { hasActiveSubscription, remainingDays, remainingOrdersCount } = + await subscriptionsService.getSubscription({ telegramId }); + + if (hasActiveSubscription && remainingDays > 0) { + await ctx.reply( + combine( + ctx.t('msg-subscription-active'), + ctx.t('msg-subscription-active-days-short', { days: remainingDays }), + remainingDays === 0 ? ctx.t('msg-subscription-expired') : '', + ), + ); + } else { + await ctx.reply( + combine( + ctx.t('msg-subscription-inactive'), + ctx.t('msg-remaining-orders-this-month', { count: remainingOrdersCount }), + remainingOrdersCount === 0 ? ctx.t('msg-subscription-expired') : '', + ), + ); + } +} + +export { handler as handlePro }; diff --git a/apps/bot/src/bot/handlers/share-bot.ts b/apps/bot/src/bot/handlers/share-bot.ts new file mode 100644 index 0000000..761e5a3 --- /dev/null +++ b/apps/bot/src/bot/handlers/share-bot.ts @@ -0,0 +1,9 @@ +import { type Context } from '@/bot/context'; +import { KEYBOARD_SHARE_BOT } from '@/config/keyboards'; + +async function handler(ctx: Context) { + await ctx.reply(ctx.t('msg-contact-forward'), { parse_mode: 'HTML' }); + await ctx.reply(ctx.t('msg-share-bot'), { ...KEYBOARD_SHARE_BOT, parse_mode: 'HTML' }); +} + +export { handler as handleShareBot }; diff --git a/apps/bot/src/bot/handlers/subscription.ts b/apps/bot/src/bot/handlers/subscription.ts new file mode 100644 index 0000000..41103a1 --- /dev/null +++ b/apps/bot/src/bot/handlers/subscription.ts @@ -0,0 +1,22 @@ +import { type Context } from '@/bot/context'; +import { SubscriptionsService } from '@repo/graphql/api/subscriptions'; + +async function handler(ctx: Context) { + const telegramId = ctx?.from?.id; + + if (!telegramId) throw new Error(ctx.t('err-missing-telegram-id')); + + const subscriptionsService = new SubscriptionsService({ telegramId }); + + const { subscriptionSetting } = await subscriptionsService.getSubscriptionSettings(); + + const proEnabled = subscriptionSetting?.proEnabled; + + if (proEnabled) { + await ctx.conversation.enter('subscription'); + } else { + await ctx.reply(ctx.t('msg-subscribe-disabled')); + } +} + +export { handler as handleSubscribe }; diff --git a/apps/bot/src/bot/index.ts b/apps/bot/src/bot/index.ts index f45859a..748dbbc 100644 --- a/apps/bot/src/bot/index.ts +++ b/apps/bot/src/bot/index.ts @@ -5,9 +5,9 @@ import { unhandledFeature } from './features/unhandled'; import { errorHandler } from './handlers/errors'; import { i18n } from './i18n'; import * as middlewares from './middlewares'; -import { setCommands } from './settings/commands'; -import { setInfo } from './settings/info'; +import { setCommands, setInfo } from './settings'; import { env } from '@/config/env'; +import { mainMenu } from '@/config/keyboards'; import { getRedisInstance } from '@/utils/redis'; import { autoChatAction, chatAction } from '@grammyjs/auto-chat-action'; import { createConversation, conversations as grammyConversations } from '@grammyjs/conversations'; @@ -51,6 +51,8 @@ export function createBot({ token }: Parameters_) { bot.use(createConversation(conversation)); } + bot.use(mainMenu); + setInfo(bot); setCommands(bot); diff --git a/apps/bot/src/config/keyboards.ts b/apps/bot/src/config/keyboards.ts index bbde0a1..18bca4d 100644 --- a/apps/bot/src/config/keyboards.ts +++ b/apps/bot/src/config/keyboards.ts @@ -1,3 +1,7 @@ +import { env } from './env'; +import { type Context } from '@/bot/context'; +import { handleAddContact, handlePro, handleShareBot, handleSubscribe } from '@/bot/handlers'; +import { Menu } from '@grammyjs/menu'; import { type InlineKeyboardMarkup, type ReplyKeyboardMarkup, @@ -30,9 +34,27 @@ export const KEYBOARD_SHARE_BOT = { [ { text: ' Воспользоваться ботом', - url: process.env.BOT_URL as string, + url: env.BOT_URL + '?start=new', }, ], ], } as InlineKeyboardMarkup, }; + +// Главное меню +export const mainMenu = new Menu('main-menu', { autoAnswer: true }) + .text((ctx) => ctx.t('btn-add-contact'), handleAddContact) + .row() + .text((ctx) => ctx.t('btn-subscribe'), handleSubscribe) + .text((ctx) => ctx.t('btn-pro-info'), handlePro) + .row() + .text((ctx) => ctx.t('btn-share-bot'), handleShareBot) + .row() + .url( + (ctx) => ctx.t('btn-open-app'), + () => { + const botUrl = new URL(env.BOT_URL); + botUrl.searchParams.set('startapp', ''); + return botUrl.toString(); + }, + ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 817dfe4..91367fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: '@grammyjs/i18n': specifier: ^1.1.2 version: 1.1.2(grammy@1.38.1) + '@grammyjs/menu': + specifier: ^1.3.1 + version: 1.3.1(grammy@1.38.1) '@grammyjs/parse-mode': specifier: ^2.2.0 version: 2.2.0(grammy@1.38.1) @@ -1374,6 +1377,12 @@ packages: peerDependencies: grammy: ^1.10.0 + '@grammyjs/menu@1.3.1': + resolution: {integrity: sha512-HJslY/n76T1Ar5qDDhNtjLs+PpcrlB9aGsXu3CJHLt147DC3K3lpiRvRW/Xh9/x9hqYVw7KKbnvsQXVgzoU81Q==} + engines: {node: '>=12.20.0 || >=14.13.1'} + peerDependencies: + grammy: ^1.31.0 + '@grammyjs/parse-mode@2.2.0': resolution: {integrity: sha512-sI5xjXYn1ihEEf1bJx4ew2KPsX1O3jsd2V/MpA1CX2tCYlxquidr7agk4IOR5bGEK38pyNVxVBdyCiy/eMxEfQ==} engines: {node: '>=14.13.1'} @@ -7771,6 +7780,10 @@ snapshots: '@fluent/langneg': 0.6.2 grammy: 1.38.1 + '@grammyjs/menu@1.3.1(grammy@1.38.1)': + dependencies: + grammy: 1.38.1 + '@grammyjs/parse-mode@2.2.0(grammy@1.38.1)': dependencies: grammy: 1.38.1