Update bot features and localization

- Added new buttons and messages in Russian localization for improved user interaction.
- Integrated '@grammyjs/menu' version 1.3.1 to enhance menu functionality.
- Refactored bot command handlers to streamline conversation flows and improve code organization.
- Updated the main menu structure to include new options for adding contacts and subscription information.
This commit is contained in:
vchikalkin 2025-09-18 18:09:35 +03:00
parent 24aabae434
commit ec3c2869c1
17 changed files with 155 additions and 74 deletions

View File

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

View File

@ -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:",

View File

@ -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<Context, Context>, 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();
}

View File

@ -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<Context>();
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 };

View File

@ -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<Context>();
@ -9,10 +8,7 @@ const composer = new Composer<Context>();
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 };

View File

@ -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<Context>();
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 };

View File

@ -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<Context>();
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 };

View File

@ -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<Context>();
// 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) => {

View File

@ -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,
});
}
// Новый пользователь — просим поделиться номером

View File

@ -0,0 +1,7 @@
import { type Context } from '@/bot/context';
async function handler(ctx: Context) {
await ctx.conversation.enter('addContact');
}
export { handler as handleAddContact };

View File

@ -0,0 +1,4 @@
export * from './add-contact';
export * from './pro';
export * from './share-bot';
export * from './subscription';

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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);

View File

@ -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<Context>('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();
},
);

13
pnpm-lock.yaml generated
View File

@ -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