diff --git a/.env.example b/.env.example index a22b853..271b9d1 100644 --- a/.env.example +++ b/.env.example @@ -20,10 +20,23 @@ TERMS_OF_SERVICE_URL=https://example.com/tos # SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app START_COMMAND_DESCRIPTION= # Description of the /start command DISABLE_WELCOME_MESSAGE= # Disable the welcome message +MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu +USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited) + +# Required channel subscription +REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join +REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens # Webhook Base URL (used for Telegram and payment providers) WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld +# Payment Method Toggles +YOOKASSA_ENABLED=True # Turn on YOOKASSA +FREEKASSA_ENABLED=True # Turn on FreeKassa +STARS_ENABLED=True # Turn on STARS +TRIBUTE_ENABLED=True # Turn on TRIBUTE +CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY + # YooKassa Payment Gateway Configuration YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa @@ -32,6 +45,12 @@ YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # YOOKASSA_VAT_CODE=1 # VAT code YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle +# FreeKassa Payment Gateway Configuration +FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa +FREEKASSA_API_KEY=your_api_key # API key for REST requests +FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications) +FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa + # CryptoBot Payment Gateway Configuration CRYPTOPAY_TOKEN= # API token for CryptoPay CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet) @@ -43,12 +62,6 @@ TRIBUTE_API_KEY= # TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments -# Payment Method Toggles -YOOKASSA_ENABLED=True # Turn on YOOKASSA -STARS_ENABLED=True # Turn on STARS -TRIBUTE_ENABLED=True # Turn on TRIBUTE -CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY - # Subscription Options. Specify cost parameters or payment links here. 1_MONTH_ENABLED=True RUB_PRICE_1_MONTH=150 diff --git a/README.md b/README.md index d5d100d..5f2a2e0 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,11 @@ ### Для пользователей: - **Регистрация и выбор языка:** Поддержка русского и английского языков. - **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию. +- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`). - **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). - **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. - **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки. -- **Оплата:** Поддержка оплаты через YooKassa, CryptoPay, Telegram Stars и Tribute. +- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute. ### Для администраторов: - **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`. @@ -27,7 +28,7 @@ - **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов. - **aiohttp:** Для запуска веб-сервера (вебхуки). - **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL. -- **YooKassa, aiocryptopay:** SDK для интеграции с платежными системами. +- **YooKassa, FreeKassa API, aiocryptopay:** Интеграции с платежными системами. - **Pydantic:** Для управления настройками из `.env` файла. - **Docker & Docker Compose:** Для контейнеризации и развертывания. @@ -66,6 +67,9 @@ | `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` | | `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` | | `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` | + | `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` | + | `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` | + | `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
@@ -81,6 +85,12 @@ | `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | | `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). | | `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. | + | `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). | + | `FREEKASSA_MERCHANT_ID` | ID вашего магазина в FreeKassa. | + | `FREEKASSA_API_KEY` | API-ключ для запросов к FreeKassa REST API. | + | `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. | + | `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. | + | `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. | | `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). | | `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
@@ -106,6 +116,9 @@ | `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. | | `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. | | `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). | + | `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). | + + > Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
@@ -125,12 +138,13 @@ Эта команда скачает образ и запустит сервис в фоновом режиме. 4. **Настройка вебхуков (Обязательно):** - Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, CryptoPay, Tribute) и панели Remnawave. + Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели Remnawave. Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом. **Пути для перенаправления:** - `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:/webhook/yookassa` + - `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:/webhook/freekassa` - `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:/webhook/cryptopay` - `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:/webhook/tribute` - `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:/webhook/panel` @@ -143,6 +157,8 @@ docker compose logs -f remnawave-tg-shop ``` + > 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут. + ## 🐳 Docker Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки. diff --git a/bot/app/controllers/dispatcher_controller.py b/bot/app/controllers/dispatcher_controller.py index f9de972..1982e95 100644 --- a/bot/app/controllers/dispatcher_controller.py +++ b/bot/app/controllers/dispatcher_controller.py @@ -13,6 +13,7 @@ from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n from bot.middlewares.ban_check_middleware import BanCheckMiddleware from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware from bot.middlewares.profile_sync import ProfileSyncMiddleware +from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> tuple[Dispatcher, Bot, Dict]: @@ -31,8 +32,8 @@ def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings)) dp.update.outer_middleware(ProfileSyncMiddleware()) dp.update.outer_middleware(BanCheckMiddleware(settings=settings, i18n_instance=i18n_instance)) + dp.update.outer_middleware(ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance)) dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings)) return dp, bot, {"i18n_instance": i18n_instance} - diff --git a/bot/app/factories/build_services.py b/bot/app/factories/build_services.py index 68b770b..b261445 100644 --- a/bot/app/factories/build_services.py +++ b/bot/app/factories/build_services.py @@ -12,6 +12,7 @@ from bot.services.stars_service import StarsService from bot.services.tribute_service import TributeService from bot.services.crypto_pay_service import CryptoPayService from bot.services.panel_webhook_service import PanelWebhookService +from bot.services.freekassa_service import FreeKassaService def build_core_services( @@ -36,6 +37,14 @@ def build_core_services( subscription_service, referral_service, ) + freekassa_service = FreeKassaService( + bot=bot, + settings=settings, + i18n=i18n, + async_session_factory=async_session_factory, + subscription_service=subscription_service, + referral_service=referral_service, + ) tribute_service = TributeService( bot, settings, @@ -70,9 +79,9 @@ def build_core_services( "promo_code_service": promo_code_service, "stars_service": stars_service, "cryptopay_service": cryptopay_service, + "freekassa_service": freekassa_service, "tribute_service": tribute_service, "panel_webhook_service": panel_webhook_service, "yookassa_service": yookassa_service, } - diff --git a/bot/app/web/web_server.py b/bot/app/web/web_server.py index afb2a43..0604ff4 100644 --- a/bot/app/web/web_server.py +++ b/bot/app/web/web_server.py @@ -27,6 +27,7 @@ async def build_and_start_web_app( "referral_service", "panel_service", "stars_service", + "freekassa_service", "cryptopay_service", "tribute_service", "panel_webhook_service", @@ -50,6 +51,7 @@ async def build_and_start_web_app( from bot.services.tribute_service import tribute_webhook_route from bot.services.crypto_pay_service import cryptopay_webhook_route from bot.services.panel_webhook_service import panel_webhook_route + from bot.services.freekassa_service import freekassa_webhook_route tribute_path = settings.tribute_webhook_path if tribute_path.startswith("/"): @@ -61,6 +63,11 @@ async def build_and_start_web_app( app.router.add_post(cp_path, cryptopay_webhook_route) logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}") + fk_path = settings.freekassa_webhook_path + if fk_path.startswith("/"): + app.router.add_post(fk_path, freekassa_webhook_route) + logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}") + # YooKassa webhook (register only when base URL present and path configured) yk_path = settings.yookassa_webhook_path if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"): @@ -88,4 +95,3 @@ async def build_and_start_web_app( # Run until cancelled await asyncio.Event().wait() - diff --git a/bot/handlers/admin/broadcast.py b/bot/handlers/admin/broadcast.py index ce3821d..be425c9 100644 --- a/bot/handlers/admin/broadcast.py +++ b/bot/handlers/admin/broadcast.py @@ -334,11 +334,18 @@ async def confirm_broadcast_callback_handler( await session.rollback() logging.error(f"Error committing broadcast logs: {e_commit}") - # Get queue stats for detailed report + # Prepare queue stats presentation queue_stats = queue_manager.get_queue_stats() - - result_message = ( - _( + back_keyboard = get_back_to_admin_panel_keyboard(current_lang, i18n) + initial_user_failed = queue_stats.get("user_failed_messages", 0) + initial_group_failed = queue_stats.get("group_failed_messages", 0) + + def build_queue_status(stats: dict) -> str: + dynamic_failed = max( + 0, stats.get("user_failed_messages", 0) - initial_user_failed + ) + max(0, stats.get("group_failed_messages", 0) - initial_group_failed) + total_failed = failed_count + dynamic_failed + return _( "broadcast_queue_result", default=( "🚀 Рассылка поставлена в очередь!\n" @@ -350,16 +357,64 @@ async def confirm_broadcast_callback_handler( "ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram." ), sent_count=sent_count, - failed_count=failed_count, - user_queue_size=queue_stats["user_queue_size"], - group_queue_size=queue_stats["group_queue_size"], + failed_count=total_failed, + user_queue_size=stats["user_queue_size"], + group_queue_size=stats["group_queue_size"], ) - ) - await callback.message.answer( + + result_message = build_queue_status(queue_stats) + + status_message = await callback.message.answer( result_message, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + reply_markup=back_keyboard, ) + async def auto_update_queue_status() -> None: + """Refresh queue stats message twice per second via message edit.""" + last_text = result_message + # Update for up to 2 minutes (240 iterations at 0.5s intervals) + max_iterations = 240 + for _ in range(max_iterations): + await asyncio.sleep(0.5) + + stats = queue_manager.get_queue_stats() + new_text = build_queue_status(stats) + queues_drained = ( + stats["user_queue_size"] == 0 + and stats["group_queue_size"] == 0 + and not stats.get("user_queue_processing") + and not stats.get("group_queue_processing") + ) + + if new_text != last_text: + try: + await status_message.edit_text( + new_text, + reply_markup=back_keyboard, + ) + last_text = new_text + except TelegramBadRequest as e: + if "message is not modified" in str(e): + last_text = new_text + else: + logging.debug( + "Broadcast queue auto-update stopped: %s", e + ) + break + except Exception as e: + logging.debug( + "Broadcast queue auto-update unexpected error: %s", e + ) + break + + if queues_drained: + # Final refresh already attempted; exit loop. + break + else: + logging.debug("Broadcast queue auto-update reached time limit.") + + asyncio.create_task(auto_update_queue_status()) + elif action == "cancel": await callback.message.edit_text( _("admin_broadcast_cancelled"), diff --git a/bot/handlers/admin/payments.py b/bot/handlers/admin/payments.py index d203e98..19aad73 100644 --- a/bot/handlers/admin/payments.py +++ b/bot/handlers/admin/payments.py @@ -39,7 +39,7 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str: _ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs) status_emoji = "✅" if payment.status == 'succeeded' else ( - "⏳" if payment.status in ['pending', 'pending_yookassa'] else "❌" + "⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌" ) user_info = f"User {payment.user_id}" @@ -54,7 +54,8 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str: 'yookassa': 'YooKassa', 'tribute': 'Tribute', 'telegram_stars': 'Telegram Stars', - 'cryptopay': 'CryptoPay' + 'cryptopay': 'CryptoPay', + 'freekassa': 'FreeKassa', }.get(payment.provider, payment.provider or 'Unknown') return ( @@ -246,4 +247,4 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data: @router.callback_query(F.data == "noop") async def noop_handler(callback: types.CallbackQuery): """Handle no-op callback (for pagination display).""" - await callback.answer() \ No newline at end of file + await callback.answer() diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py index 34561c3..726b462 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -163,8 +163,7 @@ async def show_statistics_handler(callback: types.CallbackQuery, f"\n{_('admin_stats_recent_payments_header')}") for payment in last_payments_models: status_emoji = "✅" if payment.status == 'succeeded' else ( - "⏳" if payment.status == 'pending' - or payment.status == 'pending_yookassa' else "❌") + "⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌") user_info = f"User {payment.user_id}" if payment.user and payment.user.username: diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index e6bc654..3c3ee9e 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -280,7 +280,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, details_message = _("payment_successful_error_details") details_markup = get_connect_and_main_keyboard( - user_lang, i18n, settings, config_link + user_lang, i18n, settings, config_link, preserve_message=True ) try: await bot.send_message( diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index 52f902b..ae4f4e0 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -7,10 +7,16 @@ from aiogram.fsm.context import FSMContext from typing import Optional, Union from sqlalchemy.ext.asyncio import AsyncSession from datetime import datetime, timezone +from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError from db.dal import user_dal +from db.models import User -from bot.keyboards.inline.user_keyboards import get_main_menu_inline_keyboard, get_language_selection_keyboard +from bot.keyboards.inline.user_keyboards import ( + get_main_menu_inline_keyboard, + get_language_selection_keyboard, + get_channel_subscription_keyboard, +) from bot.services.subscription_service import SubscriptionService from bot.services.panel_api_service import PanelApiService from bot.services.referral_service import ReferralService @@ -51,6 +57,7 @@ async def send_main_menu(target_event: Union[types.Message, pass return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) show_trial_button_in_menu = False @@ -116,6 +123,184 @@ async def send_main_menu(target_event: Union[types.Message, pass +async def ensure_required_channel_subscription( + event: Union[types.Message, types.CallbackQuery], + settings: Settings, + i18n: Optional[JsonI18n], + current_lang: str, + session: AsyncSession, + db_user: Optional[User] = None) -> bool: + """ + Verify that the user is a member of the required channel (if configured). + Returns True when access can proceed, False when user must subscribe first. + """ + required_channel_id = settings.REQUIRED_CHANNEL_ID + if not required_channel_id: + return True + + if isinstance(event, types.CallbackQuery): + user_id = event.from_user.id + bot_instance: Optional[Bot] = getattr(event, "bot", None) + if bot_instance is None and event.message: + bot_instance = event.message.bot + message_obj: Optional[types.Message] = event.message + else: + user_id = event.from_user.id + bot_instance = event.bot if hasattr(event, "bot") else None + message_obj = event + + if bot_instance is None: + logging.error( + "Channel subscription check: bot instance missing for user %s.", user_id + ) + return False + + if user_id in settings.ADMIN_IDS: + return True + + if db_user is None: + try: + db_user = await user_dal.get_user_by_id(session, user_id) + except Exception as fetch_error: + logging.error( + "Channel subscription check: failed to fetch user %s: %s", + user_id, + fetch_error, + exc_info=True, + ) + return False + + if not db_user: + logging.warning( + "Required channel check skipped because user %s is not persisted yet.", + user_id, + ) + return True + + if (db_user.channel_subscription_verified + and db_user.channel_subscription_verified_for + == required_channel_id): + return True + + def translate(key: str, **kwargs) -> str: + if i18n: + return i18n.gettext(current_lang, key, **kwargs) + return key + + now = datetime.now(timezone.utc) + is_member = False + status_value = None + + try: + member = await bot_instance.get_chat_member(required_channel_id, user_id) + status = getattr(member, "status", None) + status_value = getattr(status, "value", status) + allowed_statuses = {"creator", "administrator", "member", "restricted"} + if status_value in allowed_statuses: + is_member = True + except TelegramBadRequest as bad_request: + logging.info( + "Required channel check: user %s not subscribed (details: %s)", + user_id, + bad_request, + ) + except TelegramForbiddenError as forbidden_error: + logging.error( + "Required channel check failed due to insufficient permissions: %s", + forbidden_error, + ) + error_text = translate("channel_subscription_check_failed") + if isinstance(event, types.CallbackQuery): + try: + await event.answer(error_text, show_alert=True) + except Exception: + pass + if message_obj: + try: + await message_obj.answer(error_text) + except Exception: + pass + else: + await event.answer(error_text) + return False + except TelegramAPIError as api_error: + logging.error( + "Required channel check failed for user %s: %s", + user_id, + api_error, + exc_info=True, + ) + error_text = translate("channel_subscription_check_failed") + if isinstance(event, types.CallbackQuery): + try: + await event.answer(error_text, show_alert=True) + except Exception: + pass + if message_obj: + try: + await message_obj.answer(error_text) + except Exception: + pass + else: + await event.answer(error_text) + return False + + update_payload = { + "channel_subscription_checked_at": now, + "channel_subscription_verified_for": required_channel_id, + "channel_subscription_verified": is_member, + } + try: + await user_dal.update_user(session, user_id, update_payload) + except Exception as update_error: + logging.error( + "Failed to persist channel verification result for user %s: %s", + user_id, + update_error, + exc_info=True, + ) + + if is_member: + logging.info( + "User %s confirmed as member of required channel %s (status=%s).", + user_id, + required_channel_id, + status_value, + ) + return True + + keyboard = (get_channel_subscription_keyboard( + current_lang, i18n, settings.REQUIRED_CHANNEL_LINK + ) + if i18n else None) + + prompt_text = translate("channel_subscription_required") + + if isinstance(event, types.CallbackQuery): + if keyboard and event.message: + try: + await event.message.edit_text(prompt_text, reply_markup=keyboard) + except Exception as edit_error: + logging.debug( + "Failed to edit prompt message for user %s: %s", + user_id, + edit_error, + ) + if keyboard is None and message_obj: + try: + await message_obj.answer(prompt_text) + except Exception: + pass + try: + await event.answer(prompt_text, show_alert=True) + except Exception: + pass + else: + await event.answer(prompt_text, reply_markup=keyboard) + + return False + + @router.message(CommandStart()) @router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match"))) @@ -243,55 +428,60 @@ async def start_command_handler(message: types.Message, except Exception: pass + if not await ensure_required_channel_subscription(message, settings, i18n, + current_lang, session, + db_user): + return + # Send welcome message if not disabled if not settings.DISABLE_WELCOME_MESSAGE: await message.answer(_(key="welcome", user_name=hd.quote(user.full_name))) - + # Auto-apply promo code if provided via start parameter if promo_code_to_apply: try: from bot.services.promo_code_service import PromoCodeService promo_code_service = PromoCodeService(settings, subscription_service, message.bot, i18n) - + success, result = await promo_code_service.apply_promo_code( session, user_id, promo_code_to_apply, current_lang ) - + if success: await session.commit() logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}") - + # Get updated subscription details active = await subscription_service.get_active_subscription_details(session, user_id) config_link = active.get("config_link") if active else None config_link = config_link or _("config_link_not_available") - + new_end_date = result if isinstance(result, datetime) else None - + promo_success_text = _( "promo_code_applied_success_full", end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"), config_link=config_link, ) - + from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard await message.answer( promo_success_text, reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link), parse_mode="HTML" ) - + # Don't show main menu if promo was successfully applied return else: await session.rollback() logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}") # Continue to show main menu if promo failed - + except Exception as e: logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}") await session.rollback() - + await send_main_menu(message, settings, i18n_data, @@ -300,6 +490,57 @@ async def start_command_handler(message: types.Message, is_edit=False) +@router.callback_query(F.data == "channel_subscription:verify") +async def verify_channel_subscription_callback( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + subscription_service: SubscriptionService, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + + db_user = await user_dal.get_user_by_id(session, callback.from_user.id) + + verified = await ensure_required_channel_subscription( + callback, settings, i18n, current_lang, session, db_user) + if not verified: + return + + if db_user and db_user.language_code: + current_lang = db_user.language_code + i18n_data["current_language"] = current_lang + + if i18n: + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + else: + _ = lambda key, **kwargs: key + + if not settings.DISABLE_WELCOME_MESSAGE: + welcome_text = _(key="welcome", + user_name=hd.quote(callback.from_user.full_name)) + if callback.message: + await callback.message.answer(welcome_text) + else: + fallback_bot: Optional[Bot] = getattr(callback, "bot", None) + if fallback_bot: + await fallback_bot.send_message(callback.from_user.id, + welcome_text) + + try: + await callback.answer(_(key="channel_subscription_verified_success"), + show_alert=True) + except Exception: + pass + + await send_main_menu(callback, + settings, + i18n_data, + subscription_service, + session, + is_edit=bool(callback.message)) + + @router.message(Command("language")) @router.callback_query(F.data == "main_action:language") async def language_command_handler( @@ -404,10 +645,13 @@ async def main_action_callback_handler( await user_subscription_handlers.display_subscription_options( callback, i18n_data, settings, session) elif action == "my_subscription": - await user_subscription_handlers.my_subscription_command_handler( callback, i18n_data, settings, panel_service, subscription_service, session, bot) + elif action == "my_devices": + await user_subscription_handlers.my_devices_command_handler( + callback, i18n_data, settings, panel_service, subscription_service, + session, bot) elif action == "referral": await user_referral_handlers.referral_command_handler( callback, settings, i18n_data, referral_service, bot, session) @@ -427,6 +671,13 @@ async def main_action_callback_handler( subscription_service, session, is_edit=True) + elif action == "back_to_main_keep": + await send_main_menu(callback, + settings, + i18n_data, + subscription_service, + session, + is_edit=False) else: i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") _ = lambda key, **kwargs: i18n.gettext( diff --git a/bot/handlers/user/subscription/__init__.py b/bot/handlers/user/subscription/__init__.py index a4582aa..89a6c2c 100644 --- a/bot/handlers/user/subscription/__init__.py +++ b/bot/handlers/user/subscription/__init__.py @@ -12,6 +12,6 @@ router.include_router(payments.router) router.include_router(payment_methods.router) # Re-export commonly used entrypoints for backward compatibility -from .core import display_subscription_options, my_subscription_command_handler # noqa: E402,F401 +from .core import display_subscription_options, my_subscription_command_handler, my_devices_command_handler # noqa: E402,F401 diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 5fbd66e..2d7c4de 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -173,6 +173,28 @@ async def my_subscription_command_handler( ) ]) + if settings.MY_DEVICES_SECTION_ENABLED: + max_devices_value = active.get("max_devices") + max_devices_display = get_text("devices_unlimited_label") + if max_devices_value not in (None, 0): + try: + max_devices_int = int(max_devices_value) + if max_devices_int >= 0: + max_devices_display = str(max_devices_int) + except (TypeError, ValueError): + max_devices_display = str(max_devices_value) + devices_button_text = get_text( + "devices_button", + current_devices="?", + max_devices=max_devices_display, + ) + prepend_rows.append([ + InlineKeyboardButton( + text=devices_button_text, + callback_data="main_action:my_devices", + ) + ]) + # 2) Auto-renew toggle (if supported and not tribute) if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): toggle_text = ( @@ -216,6 +238,161 @@ async def my_subscription_command_handler( await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) +@router.callback_query(F.data == "main_action:my_devices") +async def my_devices_command_handler( + event: Union[types.Message, types.CallbackQuery], + i18n_data: dict, + settings: Settings, + panel_service: PanelApiService, + subscription_service: SubscriptionService, + session: AsyncSession, + bot: Bot, +): + target = event.message if isinstance(event, types.CallbackQuery) else event + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: JsonI18n = i18n_data.get("i18n_instance") + get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw) + + if not i18n or not target: + if isinstance(event, types.Message): + await event.answer(get_text("error_occurred_try_again")) + return + + if not settings.MY_DEVICES_SECTION_ENABLED: + if isinstance(event, types.CallbackQuery): + try: + await event.answer(get_text("my_devices_feature_disabled"), show_alert=True) + except Exception: + pass + else: + await target.answer(get_text("my_devices_feature_disabled")) + return + + # TODO: context? + active = await subscription_service.get_active_subscription_details(session, event.from_user.id) + if not active or not active.get("user_id"): + message = get_text("subscription_not_active") + if isinstance(event, types.CallbackQuery): + try: + await event.answer(message, show_alert=True) + except Exception: + pass + else: + await target.answer(message) + return + + devices = await panel_service.get_user_devices(active.get("user_id")) if active else None + if not devices: + if isinstance(event, types.CallbackQuery): + try: + await event.answer(get_text("no_devices_found"), show_alert=True) + except Exception: + pass + else: + await target.answer(get_text("no_devices_found")) + return + + max_devices_value = active.get("max_devices") + max_devices_display = get_text("devices_unlimited_label") + if max_devices_value not in (None, 0): + try: + max_devices_int = int(max_devices_value) + if max_devices_int >= 0: + max_devices_display = str(max_devices_int) + except (TypeError, ValueError): + max_devices_display = str(max_devices_value) + + if not devices or not devices.get('devices') or len(devices.get('devices')) == 0: + text = get_text("no_devices_details_found_message", max_devices=max_devices_display) + else: + devices_list = [] + current_devices = len(devices.get('devices') or []) + for index, device in enumerate(devices.get('devices') or [], start=1): + device_model = device.get('deviceModel') or None + platform = device.get('platform') or None + user_agent = device.get('userAgent') or None + os_version = device.get('osVersion') or None + created_at = device.get('createdAt') + hwid = device.get('hwid') + created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M") + + device_details = get_text("device_details", index=index, device_model=device_model, platform=platform, os_version=os_version, created_at_str=created_at_str, user_agent=user_agent, hwid=hwid) + devices_list.append(device_details) + + text = get_text("my_devices_details", devices="\n\n".join(devices_list), current_devices=current_devices, max_devices=max_devices_display) + + base_markup = get_back_to_main_menu_markup(current_lang, i18n, callback_data="main_action:my_subscription") + kb = base_markup.inline_keyboard + + devices_kb = [] + for index, device in enumerate(devices.get('devices') or [], start=1): + hwid = device.get('hwid') + device_button_text = get_text("disconnect_device_button", hwid=hwid, index=index) + + devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid}")]) + kb = devices_kb + kb + markup = InlineKeyboardMarkup(inline_keyboard=kb) + + if isinstance(event, types.CallbackQuery): + try: + await event.answer() + except Exception: + pass + try: + await event.message.edit_text(text, reply_markup=markup) + except Exception: + await event.message.answer(text, reply_markup=markup) + else: + await target.answer(text, reply_markup=markup) + + +@router.callback_query(F.data.startswith("disconnect_device:")) +async def disconnect_device_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + subscription_service: SubscriptionService, + panel_service: PanelApiService, + bot: Bot, +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + if not settings.MY_DEVICES_SECTION_ENABLED: + try: + await callback.answer(get_text("my_devices_feature_disabled"), show_alert=True) + except Exception: + pass + return + + try: + _, hwid = callback.data.split(":", 1) + except Exception: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + active = await subscription_service.get_active_subscription_details(session, callback.from_user.id) + if not active: + await callback.answer(get_text("subscription_not_active"), show_alert=True) + return + + success = await panel_service.disconnect_device(active.get("user_id"), hwid) + if not success: + await callback.answer(get_text("error_try_again"), show_alert=True) + return + await session.commit() + try: + await callback.answer(get_text("device_disconnected")) + except Exception: + pass + await my_devices_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot) + + @router.callback_query(F.data.startswith("toggle_autorenew:")) async def toggle_autorenew_handler( callback: types.CallbackQuery, @@ -359,5 +536,3 @@ async def connect_command_handler( ): logging.info(f"User {message.from_user.id} used /connect command.") await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot) - - diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py index 3222a35..d9815cb 100644 --- a/bot/handlers/user/subscription/payments.py +++ b/bot/handlers/user/subscription/payments.py @@ -1,11 +1,20 @@ import logging +from datetime import datetime from aiogram import Router, F, types -from typing import Optional +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from typing import Optional, List, Tuple from sqlalchemy.ext.asyncio import AsyncSession from config.settings import Settings -from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard, get_payment_url_keyboard +from bot.keyboards.inline.user_keyboards import ( + get_payment_method_keyboard, + get_payment_url_keyboard, + get_yk_autopay_choice_keyboard, + get_yk_saved_cards_keyboard, + get_back_to_main_menu_markup, +) from bot.services.yookassa_service import YooKassaService +from bot.services.freekassa_service import FreeKassaService from bot.services.crypto_pay_service import CryptoPayService from bot.services.stars_service import StarsService from bot.middlewares.i18n import JsonI18n @@ -14,6 +23,283 @@ from db.dal import payment_dal, user_billing_dal router = Router(name="user_subscription_payments_router") +def _parse_months_and_price(payload: str) -> Optional[Tuple[int, float]]: + try: + months_str, price_str = payload.split(":") + return int(months_str), float(price_str) + except (ValueError, IndexError): + return None + + +def _format_saved_payment_method_title(get_text, network: Optional[str], last4: Optional[str], is_default: bool) -> str: + def _is_yoomoney_network(name: Optional[str]) -> bool: + s = (name or "").lower() + return "yoomoney" in s or "yoo money" in s or "yoo-money" in s + + def _extract_last4(text: str) -> Optional[str]: + digits = "".join(ch for ch in text if ch.isdigit()) + return digits[-4:] if len(digits) >= 4 else None + + if _is_yoomoney_network(network): + inferred_last4 = last4 or (_extract_last4(network or "") or "****") + title = get_text("payment_method_wallet_title", last4=inferred_last4) + elif last4: + network_name = network or get_text("payment_network_card") + title = get_text("payment_method_card_title", network=network_name, last4=last4) + else: + network_name = network or get_text("payment_network_generic") + title = get_text("payment_method_generic_title", network=network_name) + return f"⭐ {title}" if is_default else title + + +async def _initiate_yk_payment( + callback: types.CallbackQuery, + *, + settings: Settings, + session: AsyncSession, + yookassa_service: YooKassaService, + i18n: Optional[JsonI18n], + current_lang: str, + get_text, + user_id: int, + months: int, + price_rub: float, + currency_code_for_yk: str, + save_payment_method: bool, + back_callback: str, + payment_method_id: Optional[str] = None, + selected_method_internal_id: Optional[int] = None, +) -> bool: + """Create payment record and initiate YooKassa payment (new card or saved card).""" + if not callback.message: + return False + + payment_description = get_text("payment_description_subscription", months=months) + payment_record_data = { + "user_id": user_id, + "amount": price_rub, + "currency": currency_code_for_yk, + "status": "pending_yookassa", + "description": payment_description, + "subscription_duration_months": months, + } + + db_payment_record = None + try: + db_payment_record = await payment_dal.create_payment_record(session, payment_record_data) + await session.commit() + logging.info( + f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." + ) + except Exception as e_db_payment: + await session.rollback() + logging.error( + f"Failed to create payment record in DB for user {user_id}: {e_db_payment}", + exc_info=True, + ) + try: + await callback.message.edit_text(get_text("error_creating_payment_record")) + except Exception: + pass + return False + + if not db_payment_record: + try: + await callback.message.edit_text(get_text("error_creating_payment_record")) + except Exception: + pass + return False + + yookassa_metadata = { + "user_id": str(user_id), + "subscription_months": str(months), + "payment_db_id": str(db_payment_record.payment_id), + } + if payment_method_id: + yookassa_metadata["used_saved_payment_method_id"] = payment_method_id + + receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL + + payment_response_yk = await yookassa_service.create_payment( + amount=price_rub, + currency=currency_code_for_yk, + description=payment_description, + metadata=yookassa_metadata, + receipt_email=receipt_email_for_yk, + save_payment_method=save_payment_method, + payment_method_id=payment_method_id, + ) + + if payment_response_yk and payment_response_yk.get("confirmation_url"): + pm = payment_response_yk.get("payment_method") + try: + if pm and pm.get("id"): + pm_type = pm.get("type") + title = pm.get("title") + card = pm.get("card") or {} + account_number = pm.get("account_number") or pm.get("account") + if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}: + display_network = card.get("card_type") or title or "Card" + display_last4 = card.get("last4") + elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}: + display_network = "YooMoney" + display_last4 = ( + account_number[-4:] + if isinstance(account_number, str) and len(account_number) >= 4 + else None + ) + else: + display_network = title or (pm_type.upper() if pm_type else "Payment method") + display_last4 = None + await user_billing_dal.upsert_yk_payment_method( + session, + user_id=user_id, + payment_method_id=pm["id"], + card_last4=display_last4, + card_network=display_network, + ) + try: + await user_billing_dal.upsert_user_payment_method( + session, + user_id=user_id, + provider_payment_method_id=pm["id"], + provider="yookassa", + card_last4=display_last4, + card_network=display_network, + set_default=save_payment_method, + ) + except Exception: + pass + await session.commit() + except Exception: + await session.rollback() + logging.exception("Failed to save YooKassa payment method preliminarily") + try: + await payment_dal.update_payment_status_by_db_id( + session, + payment_db_id=db_payment_record.payment_id, + new_status=payment_response_yk.get("status", "pending"), + yk_payment_id=payment_response_yk.get("id"), + ) + if selected_method_internal_id is not None: + try: + await user_billing_dal.set_user_default_payment_method( + session, user_id, selected_method_internal_id + ) + except Exception: + logging.exception("Failed to set default payment method after initiating payment") + await session.commit() + except Exception as e_db_update_ykid: + await session.rollback() + logging.error( + f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", + exc_info=True, + ) + try: + await callback.message.edit_text(get_text("error_payment_gateway_link_failed")) + except Exception: + pass + return False + + try: + await callback.message.edit_text( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + payment_response_yk["confirmation_url"], + current_lang, + i18n, + back_callback=back_callback, + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception as e_edit: + logging.warning( + f"Edit message for payment link failed: {e_edit}. Sending new one." + ) + try: + await callback.message.answer( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + payment_response_yk["confirmation_url"], + current_lang, + i18n, + back_callback=back_callback, + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception: + pass + return True + + if payment_response_yk and payment_method_id: + status_to_store = payment_response_yk.get("status", "pending") + try: + await payment_dal.update_payment_status_by_db_id( + session, + payment_db_id=db_payment_record.payment_id, + new_status=status_to_store, + yk_payment_id=payment_response_yk.get("id"), + ) + if selected_method_internal_id is not None: + try: + await user_billing_dal.set_user_default_payment_method( + session, user_id, selected_method_internal_id + ) + except Exception: + logging.exception("Failed to set default payment method after saved-card payment start") + await session.commit() + except Exception as e_db_update_saved: + await session.rollback() + logging.error( + f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}", + exc_info=True, + ) + try: + await callback.message.edit_text(get_text("error_payment_gateway")) + except Exception: + pass + return False + + message_text = get_text("yookassa_autopay_charge_initiated") + try: + await callback.message.edit_text( + message_text, + reply_markup=get_back_to_main_menu_markup(current_lang, i18n), + ) + except Exception as e_edit: + logging.warning(f"Failed to notify about saved-card charge start: {e_edit}") + try: + await callback.message.answer( + message_text, + reply_markup=get_back_to_main_menu_markup(current_lang, i18n), + ) + except Exception: + pass + return True + + try: + await payment_dal.update_payment_status_by_db_id( + session, db_payment_record.payment_id, "failed_creation" + ) + await session.commit() + except Exception as e_db_fail_create: + await session.rollback() + logging.error( + f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", + exc_info=True, + ) + logging.error( + f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" + ) + try: + await callback.message.edit_text(get_text("error_payment_gateway")) + except Exception: + pass + return False + + @router.callback_query(F.data.startswith("subscribe_period:")) async def select_subscription_period_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) @@ -99,13 +385,457 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti pass return + try: + _, data_payload = callback.data.split(":", 1) + except ValueError: + logging.error(f"Invalid pay_yk data in callback: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + parsed = _parse_months_and_price(data_payload) + if not parsed: + logging.error(f"Invalid pay_yk payload structure: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + months, price_rub = parsed + user_id = callback.from_user.id + currency_code_for_yk = "RUB" + autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + saved_methods: List = [] + if autopay_enabled: + try: + saved_methods = await user_billing_dal.list_user_payment_methods( + session, user_id, provider="yookassa" + ) + except Exception as e_list: + logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}") + saved_methods = [] + + if autopay_enabled and saved_methods: + try: + await callback.message.edit_text( + get_text("yookassa_autopay_flow_prompt"), + reply_markup=get_yk_autopay_choice_keyboard( + months, + price_rub, + current_lang, + i18n, + has_saved_cards=True, + ), + ) + except Exception as e_edit: + logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.") + try: + await callback.message.answer( + get_text("yookassa_autopay_flow_prompt"), + reply_markup=get_yk_autopay_choice_keyboard( + months, + price_rub, + current_lang, + i18n, + has_saved_cards=True, + ), + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + await _initiate_yk_payment( + callback, + settings=settings, + session=session, + yookassa_service=yookassa_service, + i18n=i18n, + current_lang=current_lang, + get_text=get_text, + user_id=user_id, + months=months, + price_rub=price_rub, + currency_code_for_yk=currency_code_for_yk, + save_payment_method=autopay_enabled, + back_callback=f"subscribe_period:{months}", + ) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pay_yk_new:")) +async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + if not i18n or not callback.message: + try: + await callback.answer(get_text("error_occurred_try_again"), show_alert=True) + except Exception: + pass + return + + if not yookassa_service or not yookassa_service.configured: + logging.error("YooKassa service unavailable for pay_yk_new.") + try: + await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) + except Exception: + pass + try: + await callback.message.edit_text(get_text("payment_service_unavailable")) + except Exception: + pass + return + + try: + _, data_payload = callback.data.split(":", 1) + except ValueError: + logging.error(f"Invalid pay_yk_new data in callback: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + parsed = _parse_months_and_price(data_payload) + if not parsed: + logging.error(f"Invalid pay_yk_new payload structure: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + months, price_rub = parsed + user_id = callback.from_user.id + currency_code_for_yk = "RUB" + autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + + await _initiate_yk_payment( + callback, + settings=settings, + session=session, + yookassa_service=yookassa_service, + i18n=i18n, + current_lang=current_lang, + get_text=get_text, + user_id=user_id, + months=months, + price_rub=price_rub, + currency_code_for_yk=currency_code_for_yk, + save_payment_method=autopay_enabled, + back_callback=f"subscribe_period:{months}", + ) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pay_yk_saved_list:")) +async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + if not i18n or not callback.message: + try: + await callback.answer(get_text("error_occurred_try_again"), show_alert=True) + except Exception: + pass + return + + autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + if not autopay_enabled: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + try: + _, data_payload = callback.data.split(":", 1) + except ValueError: + logging.error(f"Invalid pay_yk_saved_list data: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + parts = data_payload.split(":") + if len(parts) < 2: + logging.error(f"pay_yk_saved_list payload missing components: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + try: + months = int(parts[0]) + price_rub = float(parts[1]) + page = int(parts[2]) if len(parts) > 2 else 0 + except (ValueError, IndexError): + logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + user_id = callback.from_user.id + try: + saved_methods = await user_billing_dal.list_user_payment_methods( + session, user_id, provider="yookassa" + ) + except Exception as e_list: + logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}") + saved_methods = [] + + if not saved_methods: + try: + await callback.message.edit_text( + get_text("yookassa_autopay_no_saved_cards"), + reply_markup=get_yk_autopay_choice_keyboard( + months, + price_rub, + current_lang, + i18n, + has_saved_cards=False, + ), + ) + except Exception as e_edit: + logging.warning(f"Failed to display no-saved-card notice: {e_edit}") + try: + await callback.message.answer( + get_text("yookassa_autopay_no_saved_cards"), + reply_markup=get_yk_autopay_choice_keyboard( + months, + price_rub, + current_lang, + i18n, + has_saved_cards=False, + ), + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + cards: List[Tuple[str, str]] = [] + for method in saved_methods: + title = _format_saved_payment_method_title( + get_text, method.card_network, method.card_last4, method.is_default + ) + cards.append((str(method.method_id), title)) + + per_page = 5 + max_page = max(0, (len(cards) - 1) // per_page) + page = max(0, min(page, max_page)) + + try: + await callback.message.edit_text( + get_text("yookassa_autopay_choose_saved_card"), + reply_markup=get_yk_saved_cards_keyboard( + cards, + months, + price_rub, + current_lang, + i18n, + page=page, + ), + ) + except Exception as e_edit: + logging.warning(f"Failed to display saved card list: {e_edit}") + try: + await callback.message.answer( + get_text("yookassa_autopay_choose_saved_card"), + reply_markup=get_yk_saved_cards_keyboard( + cards, + months, + price_rub, + current_lang, + i18n, + page=page, + ), + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pay_yk_use_saved:")) +async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + if not i18n or not callback.message: + try: + await callback.answer(get_text("error_occurred_try_again"), show_alert=True) + except Exception: + pass + return + + autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + if not autopay_enabled: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + if not yookassa_service or not yookassa_service.configured: + logging.error("YooKassa service unavailable for pay_yk_use_saved.") + try: + await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) + except Exception: + pass + try: + await callback.message.edit_text(get_text("payment_service_unavailable")) + except Exception: + pass + return + + try: + _, data_payload = callback.data.split(":", 1) + except ValueError: + logging.error(f"Invalid pay_yk_use_saved data: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + parts = data_payload.split(":") + if len(parts) < 3: + logging.error(f"pay_yk_use_saved payload missing components: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + try: + months = int(parts[0]) + price_rub = float(parts[1]) + except (ValueError, IndexError): + logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + method_identifier = parts[2] + user_id = callback.from_user.id + + try: + saved_methods = await user_billing_dal.list_user_payment_methods( + session, user_id, provider="yookassa" + ) + except Exception as e_list: + logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}") + saved_methods = [] + + selected_method = None + for method in saved_methods: + if method_identifier.isdigit(): + if method.method_id == int(method_identifier): + selected_method = method + break + if method.provider_payment_method_id == method_identifier: + selected_method = method + break + + if not selected_method: + logging.warning(f"Selected payment method not found for user {user_id}: {method_identifier}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + currency_code_for_yk = "RUB" + + await _initiate_yk_payment( + callback, + settings=settings, + session=session, + yookassa_service=yookassa_service, + i18n=i18n, + current_lang=current_lang, + get_text=get_text, + user_id=user_id, + months=months, + price_rub=price_rub, + currency_code_for_yk=currency_code_for_yk, + save_payment_method=False, + back_callback=f"pay_yk_saved_list:{months}:{price_rub}", + payment_method_id=selected_method.provider_payment_method_id, + selected_method_internal_id=selected_method.method_id, + ) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pay_fk:")) +async def pay_fk_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + freekassa_service: FreeKassaService, + session: AsyncSession, +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + if not i18n or not callback.message: + try: + await callback.answer(get_text("error_occurred_try_again"), show_alert=True) + except Exception: + pass + return + + if not freekassa_service or not freekassa_service.configured: + logging.error("FreeKassa service is not configured or unavailable.") + try: + await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) + except Exception: + pass + try: + await callback.message.edit_text(get_text("payment_service_unavailable")) + except Exception: + pass + return + try: _, data_payload = callback.data.split(":", 1) months_str, price_str = data_payload.split(":") months = int(months_str) price_rub = float(price_str) except (ValueError, IndexError): - logging.error(f"Invalid pay_yk data in callback: {callback.data}") + logging.error(f"Invalid pay_fk data in callback: {callback.data}") try: await callback.answer(get_text("error_try_again"), show_alert=True) except Exception: @@ -114,149 +844,142 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti user_id = callback.from_user.id payment_description = get_text("payment_description_subscription", months=months) - currency_code_for_yk = "RUB" + currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" - payment_record_data = { + payment_record_payload = { "user_id": user_id, "amount": price_rub, - "currency": currency_code_for_yk, - "status": "pending_yookassa", + "currency": currency_code, + "status": "pending_freekassa", "description": payment_description, "subscription_duration_months": months, + "provider": "freekassa", } - db_payment_record = None try: - db_payment_record = await payment_dal.create_payment_record(session, payment_record_data) + payment_record = await payment_dal.create_payment_record(session, payment_record_payload) await session.commit() - logging.info( - f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." - ) - except Exception as e_db_payment: + except Exception as e_db_create: await session.rollback() logging.error( - f"Failed to create payment record in DB for user {user_id}: {e_db_payment}", + f"FreeKassa: failed to create payment record for user {user_id}: {e_db_create}", exc_info=True, ) - await callback.message.edit_text(get_text("error_creating_payment_record")) + try: + await callback.message.edit_text(get_text("error_creating_payment_record")) + except Exception: + pass try: await callback.answer(get_text("error_try_again"), show_alert=True) except Exception: pass return - if not db_payment_record: - await callback.message.edit_text(get_text("error_creating_payment_record")) - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - yookassa_metadata = { - "user_id": str(user_id), - "subscription_months": str(months), - "payment_db_id": str(db_payment_record.payment_id), - } - receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL - - payment_response_yk = await yookassa_service.create_payment( + success, response_data = await freekassa_service.create_order( + payment_db_id=payment_record.payment_id, + user_id=payment_record.user_id, + months=months, amount=price_rub, - currency=currency_code_for_yk, - description=payment_description, - metadata=yookassa_metadata, - receipt_email=receipt_email_for_yk, - # Save method only when autopayments are enabled - save_payment_method=bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)), + currency=freekassa_service.default_currency, + method_code=44, + ip_address=freekassa_service.server_ip, + extra_params={ + "us_method": 44, + }, ) - if payment_response_yk and payment_response_yk.get("confirmation_url"): - pm = payment_response_yk.get("payment_method") - try: - if pm and pm.get("id"): - pm_type = pm.get("type") - title = pm.get("title") - card = pm.get("card") or {} - account_number = pm.get("account_number") or pm.get("account") - if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}: - display_network = card.get("card_type") or title or "Card" - display_last4 = card.get("last4") - elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}: - display_network = "YooMoney" - display_last4 = ( - account_number[-4:] - if isinstance(account_number, str) and len(account_number) >= 4 - else None - ) - else: - display_network = title or (pm_type.upper() if pm_type else "Payment method") - display_last4 = None - await user_billing_dal.upsert_yk_payment_method( + if success: + location = response_data.get("location") + order_hash = response_data.get("orderHash") + order_id_api = response_data.get("orderId") + provider_identifier = order_hash or order_id_api + + if provider_identifier: + try: + await payment_dal.update_provider_payment_and_status( session, - user_id=user_id, - payment_method_id=pm["id"], - card_last4=display_last4, - card_network=display_network, + payment_record.payment_id, + str(provider_identifier), + payment_record.status, ) + await session.commit() + except Exception as e_status: + await session.rollback() + logging.error( + f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}", + exc_info=True, + ) + + if location: + order_identifier_display = str(order_id_api or provider_identifier or payment_record.payment_id) + order_info_text = get_text( + "free_kassa_order_info", + order_id=order_identifier_display, + date=datetime.now().strftime("%Y-%m-%d"), + ) + try: + await callback.message.edit_text( + f"{order_info_text}\n\n" + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + location, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception as e_edit: + logging.warning(f"FreeKassa: failed to display payment link ({e_edit}), sending new message.") try: - await user_billing_dal.upsert_user_payment_method( - session, - user_id=user_id, - provider_payment_method_id=pm["id"], - provider="yookassa", - card_last4=display_last4, - card_network=display_network, - set_default=True, + await callback.message.answer( + f"{order_info_text}\n\n" + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + location, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, ) except Exception: pass - await session.commit() - except Exception: - await session.rollback() - logging.exception("Failed to save YooKassa payment method preliminarily") - try: - await payment_dal.update_payment_status_by_db_id( - session, - payment_db_id=db_payment_record.payment_id, - new_status=payment_response_yk.get("status", "pending"), - yk_payment_id=payment_response_yk.get("id"), - ) - await session.commit() - except Exception as e_db_update_ykid: - await session.rollback() - logging.error( - f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", - exc_info=True, - ) - await callback.message.edit_text(get_text("error_payment_gateway_link_failed")) try: - await callback.answer(get_text("error_try_again"), show_alert=True) + await callback.answer() except Exception: pass return - await callback.message.edit_text( - get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard(payment_response_yk["confirmation_url"], current_lang, i18n), - disable_web_page_preview=False, + logging.error( + "FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s", + payment_record.payment_id, + response_data, ) else: - try: - await payment_dal.update_payment_status_by_db_id(session, db_payment_record.payment_id, "failed_creation") - await session.commit() - except Exception as e_db_fail_create: - await session.rollback() - logging.error( - f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", - exc_info=True, - ) logging.error( - f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" + "FreeKassa: create_order failed for payment %s with response %s", + payment_record.payment_id, + response_data, ) - await callback.message.edit_text(get_text("error_payment_gateway")) try: - await callback.answer() + await payment_dal.update_payment_status_by_db_id( + session, + payment_record.payment_id, + "failed_creation", + ) + await session.commit() + except Exception as e_status: + await session.rollback() + logging.error(f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True) + + try: + await callback.message.edit_text(get_text("error_payment_gateway")) + except Exception: + pass + try: + await callback.answer(get_text("error_payment_gateway"), show_alert=True) except Exception: pass @@ -314,14 +1037,26 @@ async def pay_crypto_callback_handler( try: await callback.message.edit_text( get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n), + reply_markup=get_payment_url_keyboard( + invoice_url, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), disable_web_page_preview=False, ) except Exception: try: await callback.message.answer( get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n), + reply_markup=get_payment_url_keyboard( + invoice_url, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), disable_web_page_preview=False, ) except Exception: @@ -388,6 +1123,18 @@ async def pay_stars_callback_handler( ) if payment_db_id: + try: + await callback.message.edit_text( + get_text("payment_invoice_sent_message", months=months), + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=get_text("back_to_payment_methods_button"), + callback_data=f"subscribe_period:{months}", + )] + ]), + ) + except Exception as e_edit: + logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})") try: await callback.answer() except Exception: @@ -435,4 +1182,3 @@ async def handle_successful_stars_payment( stars_amount=stars_amount, i18n_data=i18n_data, ) - diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 456eae4..96a43a2 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -118,14 +118,17 @@ def get_payment_method_keyboard(months: int, price: float, i18n_instance, settings: Settings) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() - if settings.STARS_ENABLED and stars_price is not None: - builder.button(text=_("pay_with_stars_button"), - callback_data=f"pay_stars:{months}:{stars_price}") - if settings.TRIBUTE_ENABLED and tribute_url: - builder.button(text=_("pay_with_tribute_button"), url=tribute_url) + if settings.FREEKASSA_ENABLED: + builder.button(text=_("pay_with_sbp_button"), + callback_data=f"pay_fk:{months}:{price}") if settings.YOOKASSA_ENABLED: builder.button(text=_("pay_with_yookassa_button"), callback_data=f"pay_yk:{months}:{price}") + if settings.TRIBUTE_ENABLED and tribute_url: + builder.button(text=_("pay_with_tribute_button"), url=tribute_url) + if settings.STARS_ENABLED and stars_price is not None: + builder.button(text=_("pay_with_stars_button"), + callback_data=f"pay_stars:{months}:{stars_price}") if settings.CRYPTOPAY_ENABLED: builder.button(text=_("pay_with_cryptopay_button"), callback_data=f"pay_crypto:{months}:{price}") @@ -135,17 +138,115 @@ def get_payment_method_keyboard(months: int, price: float, return builder.as_markup() -def get_payment_url_keyboard(payment_url: str, lang: str, - i18n_instance) -> InlineKeyboardMarkup: +def get_payment_url_keyboard(payment_url: str, + lang: str, + i18n_instance, + back_callback: Optional[str] = None, + back_text_key: str = "back_to_main_menu_button" + ) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() builder.button(text=_(key="pay_button"), url=payment_url) - builder.button(text=_(key="back_to_main_menu_button"), - callback_data="main_action:back_to_main") + if back_callback: + builder.button(text=_(key=back_text_key), callback_data=back_callback) + else: + builder.button(text=_(key="back_to_main_menu_button"), + callback_data="main_action:back_to_main") builder.adjust(1) return builder.as_markup() +def get_yk_autopay_choice_keyboard( + months: int, + price: float, + lang: str, + i18n_instance, + has_saved_cards: bool = True, +) -> InlineKeyboardMarkup: + """Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled.""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + price_str = str(price) + if has_saved_cards: + builder.row( + InlineKeyboardButton( + text=_(key="yookassa_autopay_pay_saved_card_button"), + callback_data=f"pay_yk_saved_list:{months}:{price_str}", + ) + ) + builder.row( + InlineKeyboardButton( + text=_(key="yookassa_autopay_pay_new_card_button"), + callback_data=f"pay_yk_new:{months}:{price_str}", + ) + ) + builder.row( + InlineKeyboardButton( + text=_(key="back_to_payment_methods_button"), + callback_data=f"subscribe_period:{months}", + ) + ) + return builder.as_markup() + + +def get_yk_saved_cards_keyboard( + cards: List[Tuple[str, str]], + months: int, + price: float, + lang: str, + i18n_instance, + page: int = 0, +) -> InlineKeyboardMarkup: + """Paginated keyboard for selecting a saved YooKassa card.""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + per_page = 5 + total = len(cards) + start = page * per_page + end = min(total, start + per_page) + price_str = str(price) + + for method_id, title in cards[start:end]: + builder.row( + InlineKeyboardButton( + text=title, + callback_data=f"pay_yk_use_saved:{months}:{price_str}:{method_id}", + ) + ) + + nav_buttons: List[InlineKeyboardButton] = [] + if start > 0: + nav_buttons.append( + InlineKeyboardButton( + text="⬅️", + callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page-1}", + ) + ) + if end < total: + nav_buttons.append( + InlineKeyboardButton( + text="➡️", + callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page+1}", + ) + ) + if nav_buttons: + builder.row(*nav_buttons) + + builder.row( + InlineKeyboardButton( + text=_(key="yookassa_autopay_pay_new_card_button"), + callback_data=f"pay_yk_new:{months}:{price_str}", + ) + ) + builder.row( + InlineKeyboardButton( + text=_(key="back_to_autopay_method_choice_button"), + callback_data=f"pay_yk:{months}:{price_str}", + ) + ) + return builder.as_markup() + + def get_referral_link_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) @@ -159,11 +260,16 @@ def get_referral_link_keyboard(lang: str, def get_back_to_main_menu_markup(lang: str, - i18n_instance) -> InlineKeyboardMarkup: + i18n_instance, + callback_data: Optional[str] = None) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() - builder.button(text=_(key="back_to_main_menu_button"), - callback_data="main_action:back_to_main") + if callback_data: + builder.button(text=_(key="back_to_main_menu_button"), + callback_data=callback_data) + else: + builder.button(text=_(key="back_to_main_menu_button"), + callback_data="main_action:back_to_main") return builder.as_markup() @@ -185,11 +291,49 @@ def get_user_banned_keyboard(support_link: Optional[str], lang: str, return builder.as_markup() +def get_channel_subscription_keyboard( + lang: str, + i18n_instance, + channel_link: Optional[str], + include_check_button: bool = True) -> Optional[InlineKeyboardMarkup]: + """ + Return keyboard with buttons to open the required channel and trigger a subscription re-check. + """ + if i18n_instance is None: + return None + + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + + has_buttons = False + + if channel_link: + builder.button( + text=_(key="channel_subscription_join_button"), + url=channel_link, + ) + has_buttons = True + + if include_check_button: + builder.button( + text=_(key="channel_subscription_verify_button"), + callback_data="channel_subscription:verify", + ) + has_buttons = True + + if not has_buttons: + return None + + builder.adjust(1) + return builder.as_markup() + + def get_connect_and_main_keyboard( lang: str, i18n_instance, settings: Settings, - config_link: Optional[str]) -> InlineKeyboardMarkup: + config_link: Optional[str], + preserve_message: bool = False) -> InlineKeyboardMarkup: """Keyboard with a connect button and a back to main menu button.""" _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() @@ -213,10 +357,11 @@ def get_connect_and_main_keyboard( ) ) + back_callback = "main_action:back_to_main_keep" if preserve_message else "main_action:back_to_main" builder.row( InlineKeyboardButton( text=_("back_to_main_menu_button"), - callback_data="main_action:back_to_main", + callback_data=back_callback, ) ) diff --git a/bot/main_bot.py b/bot/main_bot.py index e25a0dc..0a7fa10 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -199,6 +199,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher): for service_key in ( "panel_service", "cryptopay_service", + "freekassa_service", "tribute_service", "panel_webhook_service", "yookassa_service", diff --git a/bot/middlewares/channel_subscription.py b/bot/middlewares/channel_subscription.py new file mode 100644 index 0000000..3af434b --- /dev/null +++ b/bot/middlewares/channel_subscription.py @@ -0,0 +1,138 @@ +import logging +from typing import Any, Awaitable, Callable, Dict, Optional + +from aiogram import BaseMiddleware +from aiogram.types import ( + CallbackQuery, + Message, + Update, +) +from sqlalchemy.ext.asyncio import AsyncSession + +from config.settings import Settings +from db.dal import user_dal +from bot.middlewares.i18n import JsonI18n +from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard + + +class ChannelSubscriptionMiddleware(BaseMiddleware): + """ + Blocks access to handlers for users who have not yet passed the required channel subscription check. + The /start command is allowed through so that the handler can re-run the verification. + """ + + def __init__(self, settings: Settings, i18n_instance: JsonI18n): + super().__init__() + self.settings = settings + self.i18n_main_instance = i18n_instance + + async def __call__( + self, + handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], + event: Update, + data: Dict[str, Any], + ) -> Any: + required_channel_id = self.settings.REQUIRED_CHANNEL_ID + if not required_channel_id: + return await handler(event, data) + + event_user = data.get("event_from_user") + if not event_user or event_user.id in self.settings.ADMIN_IDS: + return await handler(event, data) + + # Allow /start to reach the handler so the check can be re-run. + message_object: Optional[Message] = event.message + if ( + message_object + and message_object.text + and message_object.text.startswith("/start") + ): + return await handler(event, data) + + session: AsyncSession = data["session"] + try: + db_user = await user_dal.get_user_by_id(session, event_user.id) + except Exception as db_error: + logging.error( + "ChannelSubscriptionMiddleware: failed to fetch user %s: %s", + event_user.id, + db_error, + exc_info=True, + ) + return await handler(event, data) + + if not db_user: + return await handler(event, data) + + if ( + db_user.channel_subscription_verified + and db_user.channel_subscription_verified_for == required_channel_id + ): + return await handler(event, data) + + i18n_payload: Dict[str, Any] = data.get("i18n_data", {}) + current_lang: str = i18n_payload.get( + "current_language", self.settings.DEFAULT_LANGUAGE + ) + i18n_instance: Optional[JsonI18n] = i18n_payload.get( + "i18n_instance", self.i18n_main_instance + ) + + def translate(key: str) -> str: + if i18n_instance: + return i18n_instance.gettext(current_lang, key) + return key + + keyboard = ( + get_channel_subscription_keyboard( + current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK + ) + if i18n_instance + else None + ) + prompt_text = translate("channel_subscription_required") + + if event.callback_query: + await self._handle_callback(event.callback_query, prompt_text, keyboard, data) + return + + if message_object: + await message_object.answer(prompt_text, reply_markup=keyboard) + else: + bot_instance = data["bot"] + await bot_instance.send_message( + chat_id=event_user.id, + text=prompt_text, + reply_markup=keyboard, + ) + return + + async def _handle_callback( + self, + callback: CallbackQuery, + prompt_text: str, + keyboard, + data: Dict[str, Any], + ) -> None: + try: + await callback.answer(prompt_text, show_alert=True) + except Exception: + pass + + if callback.message: + try: + await callback.message.answer(prompt_text, reply_markup=keyboard) + except Exception as send_error: + logging.error( + "ChannelSubscriptionMiddleware: failed to send prompt for callback in chat %s: %s", + callback.message.chat.id, + send_error, + exc_info=True, + ) + else: + bot_instance = data["bot"] + await bot_instance.send_message( + chat_id=callback.from_user.id, + text=prompt_text, + reply_markup=keyboard, + ) diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index 7cf8baf..b846438 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -209,7 +209,9 @@ class CryptoPayService: end_date=final_end.strftime('%Y-%m-%d'), config_link=config_link) - markup = get_connect_and_main_keyboard(lang, i18n, settings, config_link) + markup = get_connect_and_main_keyboard( + lang, i18n, settings, config_link, preserve_message=True + ) try: await bot.send_message( user_id, diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py new file mode 100644 index 0000000..662b379 --- /dev/null +++ b/bot/services/freekassa_service.py @@ -0,0 +1,406 @@ +import asyncio +from datetime import datetime +import hashlib +import hmac +import json +import logging +import time +from decimal import Decimal, ROUND_HALF_UP +from typing import Optional, Dict, Any, Tuple + +from aiohttp import ClientSession, ClientTimeout, web +from aiogram import Bot +from sqlalchemy.orm import sessionmaker + +from config.settings import Settings +from bot.middlewares.i18n import JsonI18n +from bot.services.subscription_service import SubscriptionService +from bot.services.referral_service import ReferralService +from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard +from bot.services.notification_service import NotificationService +from db.dal import payment_dal, user_dal +from bot.utils.text_sanitizer import sanitize_display_name, username_for_display + + +class FreeKassaService: + def __init__( + self, + *, + bot: Bot, + settings: Settings, + i18n: JsonI18n, + async_session_factory: sessionmaker, + subscription_service: SubscriptionService, + referral_service: ReferralService, + ): + self.bot = bot + self.settings = settings + self.i18n = i18n + self.async_session_factory = async_session_factory + self.subscription_service = subscription_service + self.referral_service = referral_service + + self.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID + self.api_key: Optional[str] = settings.FREEKASSA_API_KEY + self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET + self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() + self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP + + self.api_base_url: str = "https://api.fk.life/v1" + self._timeout = ClientTimeout(total=15) + self._session: Optional[ClientSession] = None + self._nonce_lock = asyncio.Lock() + self._last_nonce = int(time.time() * 1000) + + self.configured: bool = bool(settings.FREEKASSA_ENABLED and self.shop_id and self.api_key) + if not self.configured: + logging.warning("FreeKassaService initialized but not fully configured. Payments disabled.") + if settings.FREEKASSA_ENABLED and not self.server_ip: + logging.warning("FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider.") + + @staticmethod + def _format_amount(amount: float) -> str: + """Format amount for payloads and signature with two decimal places.""" + quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return f"{quantized:.2f}" + + async def create_order( + self, + *, + payment_db_id: int, + user_id: int, + months: int, + amount: float, + currency: Optional[str], + method_code: int, + email: Optional[str] = None, + ip_address: Optional[str] = None, + extra_params: Optional[Dict[str, Any]] = None, + ) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + logging.error("FreeKassaService is not configured. Cannot create order.") + return False, {"message": "service_not_configured"} + + ip_address = ip_address or self.server_ip + if not ip_address: + logging.error("FreeKassaService: payment IP is required but not configured.") + return False, {"message": "missing_ip"} + + email = email or f"{user_id}@telegram.org" + amount_str = self._format_amount(amount) + currency_code = (currency or self.default_currency or "RUB").upper() + + payload: Dict[str, Any] = { + "shopId": int(self.shop_id), + "nonce": await self._generate_nonce(), + "paymentId": str(payment_db_id), + "i": int(method_code), + "amount": amount_str, + "currency": currency_code, + "email": email, + "ip": ip_address, + "us_user_id": str(user_id), + "us_months": str(months), + "us_payment_db_id": str(payment_db_id), + } + + if extra_params: + for key, value in extra_params.items(): + if value is None: + continue + payload[key] = value + + payload["signature"] = self._sign_payload(payload) + + session = await self._get_session() + url = f"{self.api_base_url}/orders/create" + try: + async with session.post(url, json=payload) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else {} + except json.JSONDecodeError: + logging.error("FreeKassa create_order: failed to decode JSON: %s", response_text) + return False, {"status": response.status, "message": "invalid_json", "raw": response_text} + + if response.status != 200 or response_data.get("type") != "success": + logging.error( + "FreeKassa create_order: API returned error (status=%s, body=%s)", + response.status, + response_data, + ) + return False, {"status": response.status, "message": response_data} + + return True, response_data + except Exception as exc: + logging.error("FreeKassa create_order: request failed: %s", exc, exc_info=True) + return False, {"message": str(exc)} + + async def _get_session(self) -> ClientSession: + if self._session is None or self._session.closed: + self._session = ClientSession(timeout=self._timeout) + return self._session + + async def _generate_nonce(self) -> int: + async with self._nonce_lock: + candidate = int(time.time() * 1000) + if candidate <= self._last_nonce: + candidate = self._last_nonce + 1 + self._last_nonce = candidate + return candidate + + def _sign_payload(self, payload: Dict[str, Any]) -> str: + if not self.api_key: + raise RuntimeError("FreeKassa API key is not configured.") + items = [ + (key, value) + for key, value in payload.items() + if key != "signature" and value is not None + ] + items.sort(key=lambda pair: pair[0]) + message = "|".join(str(value) for _, value in items) + return hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest() + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + + def _validate_signature( + self, + merchant_order_id: str, + amount: str, + provided_signature: str, + payload: Optional[Dict[str, Any]] = None, + ) -> bool: + if not provided_signature: + return False + + if self.shop_id and self.second_secret: + signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}" + expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest() + if expected_signature.lower() == provided_signature.lower(): + return True + + if self.api_key and payload: + items = [ + (key, value) + for key, value in payload.items() + if key not in {"signature", "SIGN"} and value is not None + ] + items.sort(key=lambda pair: pair[0]) + message = "|".join(str(value) for _, value in items) + alt_signature = hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest() + if alt_signature.lower() == provided_signature.lower(): + return True + + return False + + async def webhook_route(self, request: web.Request) -> web.Response: + if not self.configured: + return web.Response(status=503, text="freekassa_disabled") + + try: + data = await request.post() + except Exception as e: + logging.error(f"FreeKassa webhook: failed to read POST data: {e}") + return web.Response(status=400, text="bad_request") + + payload_dict: Dict[str, Any] + if data: + payload_dict = {str(k): v for k, v in data.items()} + else: + try: + json_payload = await request.json() + payload_dict = {str(k): v for k, v in json_payload.items()} if isinstance(json_payload, dict) else {} + data = json_payload + except Exception: + payload_dict = {} + data = {} + + def _get(key: str, default: Optional[str] = None) -> Optional[str]: + if isinstance(data, dict): + return data.get(key) or data.get(key.lower()) or default + return payload_dict.get(key) or payload_dict.get(key.lower()) or default + + merchant_id = _get("MERCHANT_ID") + if merchant_id != self.shop_id: + logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})") + return web.Response(status=403, text="merchant_mismatch") + + signature = _get("SIGN") or _get("signature") + if not signature: + logging.error("FreeKassa webhook: missing signature") + return web.Response(status=400, text="missing_signature") + + order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o") + amount_str = _get("AMOUNT") or _get("OA") or _get("amount") + provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id") + + if not order_id_str or not amount_str: + logging.error("FreeKassa webhook: missing order_id or amount") + return web.Response(status=400, text="missing_data") + + if not self._validate_signature(order_id_str, amount_str, signature, payload_dict): + logging.error("FreeKassa webhook: invalid signature") + return web.Response(status=403, text="invalid_signature") + + try: + payment_db_id = int(order_id_str) + except (TypeError, ValueError): + logging.error(f"FreeKassa webhook: invalid order_id value '{order_id_str}'") + return web.Response(status=400, text="invalid_order_id") + + async with self.async_session_factory() as session: + payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) + if not payment: + logging.error(f"FreeKassa webhook: payment {payment_db_id} not found") + return web.Response(status=404, text="payment_not_found") + + if payment.status == "succeeded": + logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded") + return web.Response(text="YES") + + # Optional amount verification + try: + amount_decimal = Decimal(amount_str) + expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + if amount_decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) != expected_amount: + logging.warning( + f"FreeKassa webhook: amount mismatch for payment {payment_db_id} " + f"(expected {expected_amount}, got {amount_decimal})" + ) + except Exception as e: + logging.warning(f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}") + + activation = None + referral_bonus = None + try: + await payment_dal.update_provider_payment_and_status( + session=session, + payment_db_id=payment.payment_id, + provider_payment_id=str(provider_payment_id or f"freekassa:{order_id_str}"), + new_status="succeeded", + ) + + months = payment.subscription_duration_months or 1 + + activation = await self.subscription_service.activate_subscription( + session, + payment.user_id, + months, + float(payment.amount), + payment.payment_id, + provider="freekassa", + ) + + referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( + session, + payment.user_id, + months, + current_payment_db_id=payment.payment_id, + skip_if_active_before_payment=False, + ) + + await session.commit() + except Exception as e: + await session.rollback() + logging.error(f"FreeKassa webhook: failed to process payment {payment_db_id}: {e}", exc_info=True) + return web.Response(status=500, text="processing_error") + + db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id) + lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k + + config_link = None + final_end = None + months = payment.subscription_duration_months or 1 + if activation: + config_link = activation.get("subscription_url") + final_end = activation.get("end_date") + + applied_days = 0 + if referral_bonus and referral_bonus.get("referee_new_end_date"): + final_end = referral_bonus["referee_new_end_date"] + applied_days = referral_bonus.get("referee_bonus_applied_days", 0) + + if not final_end and activation and activation.get("end_date"): + final_end = activation["end_date"] + + if not config_link: + config_link = _("config_link_not_available") + if final_end: + end_date_str = final_end.strftime("%Y-%m-%d") + else: + end_date_str = _("config_link_not_available") + + if applied_days: + inviter_name_display = _("friend_placeholder") + if db_user and db_user.referred_by_id: + inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id) + if inviter: + safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None + if safe_name: + inviter_name_display = safe_name + elif inviter.username: + inviter_name_display = username_for_display(inviter.username, with_at=False) + text = _( + "payment_successful_with_referral_bonus_full", + months=months, + base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else end_date_str, + bonus_days=applied_days, + final_end_date=end_date_str, + inviter_name=inviter_name_display, + config_link=config_link, + ) + else: + text = _( + "payment_successful_full", + months=months, + end_date=end_date_str, + config_link=config_link, + ) + if provider_payment_id: + order_info_text = _( + "free_kassa_order_full", + order_id=provider_payment_id, + date=datetime.now().strftime("%Y-%m-%d"), + ) + text = f"{order_info_text}\n{text}" + + markup = get_connect_and_main_keyboard( + lang, + self.i18n, + self.settings, + config_link, + preserve_message=True, + ) + try: + await self.bot.send_message( + payment.user_id, + text, + reply_markup=markup, + parse_mode="HTML", + disable_web_page_preview=True, + ) + except Exception as e: + logging.error(f"FreeKassa notification: failed to send message to user {payment.user_id}: {e}") + + try: + notification_service = NotificationService(self.bot, self.settings, self.i18n) + await notification_service.notify_payment_received( + user_id=payment.user_id, + amount=float(payment.amount), + currency=self.default_currency, + months=months, + payment_provider="freekassa", + username=db_user.username if db_user else None, + ) + except Exception as e: + logging.error(f"FreeKassa notification: failed to notify admins: {e}") + + return web.Response(text="YES") + + +async def freekassa_webhook_route(request: web.Request) -> web.Response: + service: FreeKassaService = request.app["freekassa_service"] + return await service.webhook_route(request) diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index 9a774b8..24f1a39 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -158,6 +158,7 @@ class NotificationService: provider_emoji = { "yookassa": "💳", + "freekassa": "💳", "cryptopay": "₿", "stars": "⭐", "tribute": "💎" diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index 2f036b1..60586d0 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -21,11 +21,11 @@ class PanelApiService: self.api_key = settings.PANEL_API_KEY self._session: Optional[aiohttp.ClientSession] = None self.default_client_ip = "127.0.0.1" - + async def __aenter__(self): """Context manager entry""" return self - + async def __aexit__(self, exc_type, exc_val, exc_tb): """Context manager exit - automatically close session""" await self.close_session() @@ -337,6 +337,7 @@ class PanelApiService: default_expire_days: int = 1, default_traffic_limit_bytes: int = 0, default_traffic_limit_strategy: str = "NO_RESET", + hwid_device_limit: Optional[int] = None, specific_squad_uuids: Optional[List[str]] = None, description: Optional[str] = None, tag: Optional[str] = None, @@ -368,6 +369,18 @@ class PanelApiService: "trafficLimitStrategy": default_traffic_limit_strategy.upper(), "trafficLimitBytes": default_traffic_limit_bytes, } + hwid_limit_value = hwid_device_limit + if hwid_limit_value is None: + hwid_limit_value = self.settings.USER_HWID_DEVICE_LIMIT + if hwid_limit_value is not None: + try: + hwid_limit_int = int(hwid_limit_value) + if hwid_limit_int >= 0: + payload["hwidDeviceLimit"] = hwid_limit_int + except (TypeError, ValueError): + logging.warning( + f"Ignoring invalid HWID device limit '{hwid_limit_value}' while creating panel user '{username_on_panel}'." + ) if specific_squad_uuids: payload["activeInternalSquads"] = specific_squad_uuids if telegram_id is not None: payload["telegramId"] = telegram_id @@ -455,6 +468,30 @@ class PanelApiService: return f"{base_sub_url}/{client_type.lower()}" return base_sub_url + async def get_user_devices(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]: + endpoint = f"/hwid/devices/{user_uuid}" + response_data = await self._request("GET", endpoint, log_full_response=False) + if response_data and not response_data.get("error") and "response" in response_data: + return response_data.get("response") + logging.error( + f"Failed to get user devices for user {user_uuid}. Response: {response_data}" + ) + return None + + async def disconnect_device(self, user_uuid: str, hwid: str) -> bool: + endpoint = f"/hwid/devices/delete" + payload = { + "userUuid": user_uuid, + "hwid": hwid + } + response_data = await self._request("POST", endpoint, json=payload, log_full_response=False) + if response_data and not response_data.get("error") and "response" in response_data: + return True + logging.error( + f"Failed to disconnect device {hwid} for user {user_uuid}. Payload: {payload}, Response: {response_data}" + ) + return False + async def update_bot_db_sync_status(self, session: AsyncSession, status: str, @@ -468,25 +505,24 @@ class PanelApiService: async def get_bot_db_last_sync_status( self, session: AsyncSession) -> Optional[PanelSyncStatus]: return await panel_sync_dal.get_panel_sync_status(session) - - + async def get_system_stats(self) -> Optional[Dict[str, Any]]: """Get system statistics (CPU, memory, users counts)""" response_data = await self._request("GET", "/system/stats", log_full_response=False) if response_data and not response_data.get("error") and "response" in response_data: return response_data.get("response") return None - + async def get_bandwidth_stats(self) -> Optional[Dict[str, Any]]: """Get bandwidth statistics""" response_data = await self._request("GET", "/system/stats/bandwidth", log_full_response=False) if response_data and not response_data.get("error") and "response" in response_data: return response_data.get("response") return None - + async def get_nodes_statistics(self) -> Optional[Dict[str, Any]]: """Get nodes statistics""" response_data = await self._request("GET", "/system/stats/nodes", log_full_response=False) if response_data and not response_data.get("error") and "response" in response_data: return response_data.get("response") - return None \ No newline at end of file + return None diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index 17173a2..e278796 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -148,7 +148,7 @@ class StarsService: config_link=config_link, ) markup = get_connect_and_main_keyboard( - current_lang, i18n, self.settings, config_link + current_lang, i18n, self.settings, config_link, preserve_message=True ) try: await self.bot.send_message( diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index eb71475..73fe993 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -593,10 +593,10 @@ class SubscriptionService: ) start_date = datetime.now(timezone.utc) new_end_date_obj = start_date + timedelta(days=bonus_days) - + # For promo code activations, use the configured user traffic limit traffic_limit = self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else self.settings.trial_traffic_limit_bytes - + bonus_sub_payload = { "user_id": user_id, "panel_user_uuid": panel_uuid, @@ -635,7 +635,7 @@ class SubscriptionService: ), include_uuid=False, ) - + panel_update_success = ( await self.panel_service.update_user_details_on_panel( panel_uuid, @@ -739,8 +739,12 @@ class SubscriptionService: if panel_user_data.get("expireAt") else None ) + hwid_limit = panel_user_data.get("hwidDeviceLimit") + if hwid_limit is None: + hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT return { + "user_id": panel_user_data.get("uuid"), "end_date": panel_end_date, "status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(), "config_link": panel_user_data.get("subscriptionUrl"), @@ -748,6 +752,7 @@ class SubscriptionService: "traffic_used_bytes": panel_user_data.get("usedTrafficBytes"), "user_bot_username": db_user.username, "is_panel_data": True, + "max_devices": hwid_limit, } async def get_subscriptions_ending_soon( diff --git a/bot/services/tribute_service.py b/bot/services/tribute_service.py index de12e55..0e48ce7 100644 --- a/bot/services/tribute_service.py +++ b/bot/services/tribute_service.py @@ -208,7 +208,11 @@ class TributeService: config_link=config_link, ) markup = get_connect_and_main_keyboard( - lang, i18n, settings, config_link + lang, + i18n, + settings, + config_link, + preserve_message=True, ) try: diff --git a/bot/utils/message_queue.py b/bot/utils/message_queue.py index 40e3c0b..0f9314c 100644 --- a/bot/utils/message_queue.py +++ b/bot/utils/message_queue.py @@ -26,6 +26,8 @@ class MessageQueue: self.last_send_times: deque[datetime] = deque() self.is_processing = False self.delay_between_messages = 1.0 / messages_per_second + self.total_sent = 0 + self.total_failed = 0 async def add_message(self, message: QueuedMessage) -> None: """Add message to queue""" @@ -50,6 +52,7 @@ class MessageQueue: try: await self._send_message(message) self.last_send_times.append(datetime.now()) + self.total_sent += 1 # Keep only recent send times (last minute) cutoff_time = datetime.now() - timedelta(seconds=60) @@ -57,6 +60,7 @@ class MessageQueue: self.last_send_times.popleft() except Exception as e: + self.total_failed += 1 logging.error(f"Failed to send queued message to {message.chat_id}: {e}") finally: @@ -233,7 +237,11 @@ class MessageQueueManager: "group_queue_processing": self.group_queue.is_processing, "user_queue_processing": self.user_queue.is_processing, "group_recent_sends": len(self.group_queue.last_send_times), - "user_recent_sends": len(self.user_queue.last_send_times) + "user_recent_sends": len(self.user_queue.last_send_times), + "group_failed_messages": self.group_queue.total_failed, + "user_failed_messages": self.user_queue.total_failed, + "group_sent_messages": self.group_queue.total_sent, + "user_sent_messages": self.user_queue.total_sent, } @@ -250,4 +258,4 @@ def init_queue_manager(bot: Bot) -> MessageQueueManager: def get_queue_manager() -> Optional[MessageQueueManager]: """Get global queue manager instance""" - return _queue_manager \ No newline at end of file + return _queue_manager diff --git a/config/settings.py b/config/settings.py index 21878b4..f7a86a9 100644 --- a/config/settings.py +++ b/config/settings.py @@ -23,6 +23,12 @@ class Settings(BaseSettings): SUPPORT_LINK: Optional[str] = Field(default=None) SERVER_STATUS_URL: Optional[str] = Field(default=None) TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None) + REQUIRED_CHANNEL_ID: Optional[int] = Field( + default=None, + description="Telegram channel ID the user must join to access the bot") + REQUIRED_CHANNEL_LINK: Optional[str] = Field( + default=None, + description="Public username or invite link to the required channel for join button") YOOKASSA_SHOP_ID: Optional[str] = None YOOKASSA_SECRET_KEY: Optional[str] = None @@ -44,6 +50,14 @@ class Settings(BaseSettings): CRYPTOPAY_ASSET: str = Field(default="RUB") CRYPTOPAY_ENABLED: bool = Field(default=True) + FREEKASSA_ENABLED: bool = Field(default=False) + FREEKASSA_MERCHANT_ID: Optional[str] = None + FREEKASSA_FIRST_SECRET: Optional[str] = None + FREEKASSA_SECOND_SECRET: Optional[str] = None + FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/") + FREEKASSA_API_KEY: Optional[str] = None + FREEKASSA_PAYMENT_IP: Optional[str] = None + YOOKASSA_ENABLED: bool = Field(default=True) STARS_ENABLED: bool = Field(default=True) TRIBUTE_ENABLED: bool = Field(default=True) @@ -123,6 +137,15 @@ class Settings(BaseSettings): START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None) DISABLE_WELCOME_MESSAGE: bool = Field(default=False, description="Disable welcome message on /start command") + + MY_DEVICES_SECTION_ENABLED: bool = Field( + default=False, + description="Enable the My Devices section in the subscription menu" + ) + USER_HWID_DEVICE_LIMIT: Optional[int] = Field( + default=None, + description="Default hardware device limit for panel users (0 = unlimited)" + ) # Inline mode thumbnail URLs INLINE_REFERRAL_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/1077/1077114.png") @@ -236,6 +259,19 @@ class Settings(BaseSettings): return f"{base.rstrip('/')}{self.cryptopay_webhook_path}" return None + @computed_field + @property + def freekassa_webhook_path(self) -> str: + return "/webhook/freekassa" + + @computed_field + @property + def freekassa_full_webhook_url(self) -> Optional[str]: + base = self.WEBHOOK_BASE_URL + if base: + return f"{base.rstrip('/')}{self.freekassa_webhook_path}" + return None + # Computed YooKassa receipt fields based on recurring toggle @computed_field @property @@ -331,6 +367,22 @@ class Settings(BaseSettings): if isinstance(v, str) and v.strip() == '': return None return v + + @field_validator('REQUIRED_CHANNEL_LINK', mode='before') + @classmethod + def sanitize_optional_link(cls, v): + if isinstance(v, str) and not v.strip(): + return None + return v + + @field_validator('USER_HWID_DEVICE_LIMIT', mode='before') + @classmethod + def validate_optional_int(cls, v): + if isinstance(v, str): + v = v.strip() + if not v: + return None + return v # Notification types LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations") @@ -366,6 +418,22 @@ def get_settings() -> Settings: logging.warning( "CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work." ) + if _settings_instance.FREEKASSA_ENABLED: + if ( + not _settings_instance.FREEKASSA_MERCHANT_ID + or not _settings_instance.FREEKASSA_API_KEY + ): + logging.warning( + "CRITICAL: FreeKassa is enabled but SHOP_ID or API key is missing. FreeKassa payments will not work." + ) + if not _settings_instance.FREEKASSA_SECOND_SECRET: + logging.warning( + "WARNING: FreeKassa second secret is not set. Incoming payment notifications cannot be verified." + ) + if not _settings_instance.subscription_options: + logging.warning( + "CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons." + ) except ValidationError as e: logging.critical( diff --git a/db/database_setup.py b/db/database_setup.py index 4c1fbf8..711c11b 100644 --- a/db/database_setup.py +++ b/db/database_setup.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import sessionmaker from config.settings import Settings from .models import Base -from .migrator import run_simple_migrations +from .migrator import run_database_migrations async_engine = None @@ -63,8 +63,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker): async with async_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) - # Run lightweight, idempotent migrations to add any missing columns - await conn.run_sync(run_simple_migrations) + await conn.run_sync(run_database_migrations) logging.info( "PostgreSQL database initialized/checked successfully using SQLAlchemy." ) diff --git a/db/migrator.py b/db/migrator.py index 3f37cc0..e91cb53 100644 --- a/db/migrator.py +++ b/db/migrator.py @@ -1,66 +1,98 @@ import logging -from typing import Set +from dataclasses import dataclass +from typing import Callable, List, Set from sqlalchemy import inspect, text from sqlalchemy.engine import Connection -from .models import Base + +@dataclass(frozen=True) +class Migration: + id: str + description: str + upgrade: Callable[[Connection], None] -def _add_missing_columns(connection: Connection) -> None: +def _ensure_migrations_table(connection: Connection) -> None: + connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + id VARCHAR(255) PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) + ) + + +def _migration_0001_add_channel_subscription_fields(connection: Connection) -> None: inspector = inspect(connection) - metadata = Base.metadata + columns: Set[str] = {col["name"] for col in inspector.get_columns("users")} + statements: List[str] = [] - existing_tables: Set[str] = set(inspector.get_table_names()) + if "channel_subscription_verified" not in columns: + statements.append( + "ALTER TABLE users ADD COLUMN channel_subscription_verified BOOLEAN" + ) + if "channel_subscription_checked_at" not in columns: + statements.append( + "ALTER TABLE users ADD COLUMN channel_subscription_checked_at TIMESTAMPTZ" + ) + if "channel_subscription_verified_for" not in columns: + statements.append( + "ALTER TABLE users ADD COLUMN channel_subscription_verified_for BIGINT" + ) - for table in metadata.tables.values(): - table_name = table.name - if table_name not in existing_tables: - # Tables are created elsewhere via create_all; skip here. + for stmt in statements: + connection.execute(text(stmt)) + + +MIGRATIONS: List[Migration] = [ + Migration( + id="0001_add_channel_subscription_fields", + description="Add columns to track required channel subscription verification", + upgrade=_migration_0001_add_channel_subscription_fields, + ), +] + + +def run_database_migrations(connection: Connection) -> None: + """ + Apply pending migrations sequentially. Already applied revisions are skipped. + """ + _ensure_migrations_table(connection) + + applied_revisions: Set[str] = { + row[0] + for row in connection.execute( + text("SELECT id FROM schema_migrations") + ) + } + + for migration in MIGRATIONS: + if migration.id in applied_revisions: continue - existing_columns = {col_info["name"] for col_info in inspector.get_columns(table_name)} - - for desired_column in table.columns: - if desired_column.name in existing_columns: - continue - - # Build ADD COLUMN DDL - preparer = connection.dialect.identifier_preparer - table_quoted = preparer.format_table(table) - column_name_quoted = preparer.quote(desired_column.name) - column_type_sql = desired_column.type.compile(dialect=connection.dialect) - - default_clause = "" - server_default = getattr(desired_column, "server_default", None) - if server_default is not None and getattr(server_default, "arg", None) is not None: - try: - compiled_default = str( - server_default.arg.compile(dialect=connection.dialect) - ) - default_clause = f" DEFAULT {compiled_default}" - except Exception: # best-effort - pass - - # For safety, add new columns as NULLable to avoid failures on existing rows - # If strict NOT NULL is needed, it can be enforced manually later. - ddl = f"ALTER TABLE {table_quoted} ADD COLUMN {column_name_quoted} {column_type_sql}{default_clause}" - - logging.info( - f"Migrator: adding missing column {desired_column.name} to table {table_name}" + logging.info( + "Migrator: applying %s – %s", migration.id, migration.description + ) + try: + with connection.begin_nested(): + migration.upgrade(connection) + connection.execute( + text( + "INSERT INTO schema_migrations (id) VALUES (:revision)" + ), + {"revision": migration.id}, + ) + except Exception as exc: + logging.error( + "Migrator: failed to apply %s (%s)", + migration.id, + migration.description, + exc_info=True, ) - connection.execute(text(ddl)) - - -def run_simple_migrations(connection: Connection) -> None: - """ - Run lightweight, idempotent migrations: - - Ensure missing columns are added to existing tables to match models in db/models.py - Note: Table creation is handled separately via Base.metadata.create_all. - """ - try: - _add_missing_columns(connection) - logging.info("Migrator: schema synchronized (columns added as needed).") - except Exception as e: - logging.error(f"Migrator: failed to run simple migrations: {e}", exc_info=True) - raise + raise exc + else: + logging.info("Migrator: migration %s applied successfully", migration.id) diff --git a/db/models.py b/db/models.py index d33dfdf..5e8d079 100644 --- a/db/models.py +++ b/db/models.py @@ -24,6 +24,10 @@ class User(Base): referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True) + channel_subscription_verified = Column(Boolean, nullable=True) + channel_subscription_checked_at = Column(DateTime(timezone=True), + nullable=True) + channel_subscription_verified_for = Column(BigInteger, nullable=True) referrer = relationship("User", remote_side=[user_id], backref="referrals") subscriptions = relationship("Subscription", diff --git a/locales/en.json b/locales/en.json index 6d6b0a3..46071a4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,11 +1,15 @@ { "welcome": "Welcome, {user_name}!", + "channel_subscription_required": "To use the bot, join the channel and tap \"Check subscription\".", + "channel_subscription_join_button": "Open channel", + "channel_subscription_verify_button": "Check subscription", + "channel_subscription_check_failed": "Couldn't verify the subscription. Please try again later or contact support.", + "channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.", "main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?", - "menu_activate_trial_button": "🆓 Free Trial", "menu_subscribe_inline": "🚀 Purchase", "menu_my_subscription_inline": "🔐 My Subscription", - "no_subscription options available": "Subscription issuance is not configured by the bot administrator", + "no_subscription_options_available": "Subscription issuance is not configured by the bot administrator", "menu_referral_inline": "🎁 Referrals", "referral_no_bonuses_configured": "Sorry, the referral program is currently disabled", "menu_apply_promo_button": "🎟 Promo Code", @@ -13,37 +17,53 @@ "menu_server_status_button": "📊 Status", "menu_support_button": "💬 Support", "menu_terms_button": "📄 Terms of Service", - "back_to_main_menu_button": "⬅️ Back", - "choose_language": "Choose language:", "language_set_alert": "Language changed!", - "error_occurred_try_again": "An error occurred, please try again.", "error_try_again": "Please try again.", "error_displaying_menu": "Error displaying menu.", "main_menu_unknown_action": "Unknown action.", - "select_subscription_period": "Select subscription period:", "subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}", - "choose_payment_method": "Choose payment method:", "pay_button": "💳 Pay", "pay_with_yookassa_button": "💳 YooKassa", + "yookassa_autopay_flow_prompt": "Auto-renew is enabled. Choose how you'd like to pay:", + "yookassa_autopay_pay_saved_card_button": "💳 Pay with saved card", + "yookassa_autopay_pay_new_card_button": "➕ Pay with new card", + "yookassa_autopay_choose_saved_card": "Choose a saved card to charge:", + "yookassa_autopay_no_saved_cards": "No saved cards found. Pay with a new card or link one in Payment Methods.", + "back_to_autopay_method_choice_button": "⬅️ Back", + "yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.", + "pay_with_sbp_button": "📱 SBP", + "back_to_payment_methods_button": "⬅️ Back", "pay_with_cryptopay_button": "💎 CryptoBot", "pay_with_tribute_button": "❤️ Tribute", "pay_with_stars_button": "🌟 Telegram Stars", "connect_button": "🔗 Connect", "cancel_button": "❌ Cancel", + + "devices_button": "📱 My Devices ({current_devices}/{max_devices})", + "my_devices_details": "📱 My Devices ({current_devices}/{max_devices})\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\n
Note: If you disconnect a device, it will be automatically connected again when you use it next. Before deleting, make sure you have deleted the subscription from the application.
", + "no_devices_details_found_message": "📱 My Devices\n\nYou don't have any devices yet.\nYou have {max_devices} devices available. You can connect them by clicking the \"🔗 Connect\" button in the subscription menu.", + "device_details": "{index}) {device_model} ({platform} {os_version})\nDate connected: {created_at_str}\nUser Agent: {user_agent}\nHWID: {hwid}", + "disconnect_device_button": "🚫 Disconnect {hwid} ({index})", + "device_disconnected": "Device disconnected successfully.", + "no_devices_found": "Error loading device list.", + "devices_unlimited_label": "Unlimited", + "my_devices_feature_disabled": "The My Devices section is currently unavailable.", + "payment_description_subscription": "Subscription payment for {months} mo.", "payment_link_message": "To pay for {months} mo. subscription, click the button below:", + "free_kassa_order_info": "Order #{order_id} from {date}", + "payment_invoice_sent_message": "Telegram has sent the invoice above. Complete the payment or pick another method below.", "payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.", "payment_successful_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n{config_link}\n\nTo connect, open the link and follow the instructions 👇", "payment_successful_with_referral_bonus_full": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.\n\nConnection key:\n{config_link}\n\nTo connect, open the link and follow the instructions 👇", "payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.", "config_link_not_available": "not available, contact support", "traffic_unlimited": "Unlimited", - "promo_code_prompt": "Please enter your promo code:", "promo_code_not_found": "Promo code {code} not found, expired, or already used the maximum number of times.", "promo_code_already_used_by_user": "You have already used promo code {code}.", @@ -65,7 +85,6 @@ "referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.", "referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.", "user_is_banned": "🚫 Your account is banned. Please contact support.", - "admin_panel_title": "Admin Panel", "admin_stats_button": "📊 Statistics", "admin_broadcast_button": "📢 Broadcast", @@ -322,7 +341,6 @@ "admin_user_subscription_active_until": "⏰ Active until:", "admin_user_subscription_error": "Loading error", "admin_promo_management_button": "🎟 Promo Management", - "admin_promo_management_title": "🎟 Promo Code Management\n\nSelect a promo code for detailed view:", "admin_promo_management_empty": "📭 No promo codes available", "admin_promo_card_title": "🎟 Promo Code: {code}", @@ -435,5 +453,6 @@ "admin_ads_delete_button": "🗑 Delete campaign", "admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.", "admin_ads_deleted_success": "Campaign deleted.", - "admin_ads_not_found": "Campaign not found." + "admin_ads_not_found": "Campaign not found.", + "free_kassa_order_full": "Order #{order_id} from {date}\n\n" } diff --git a/locales/ru.json b/locales/ru.json index 686bc23..77c8475 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1,7 +1,11 @@ { "welcome": "Добро пожаловать, {user_name}!", + "channel_subscription_required": "Чтобы пользоваться ботом, подпишитесь на канал и нажмите \"Проверить подписку\".", + "channel_subscription_join_button": "Перейти в канал", + "channel_subscription_verify_button": "Проверить подписку", + "channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.", + "channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.", "main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?", - "menu_activate_trial_button": "🆓 Пробный период", "menu_subscribe_inline": "🚀 Купить", "menu_my_subscription_inline": "🔐 Моя подписка", @@ -13,37 +17,53 @@ "menu_server_status_button": "📊 Статус", "menu_support_button": "💬 Поддержка", "menu_terms_button": "📄 Условия сервиса", - "back_to_main_menu_button": "⬅️ Назад", - "choose_language": "Выберите язык / Select language:", "language_set_alert": "Язык изменен!", - "error_occurred_try_again": "Произошла ошибка, попробуйте снова.", "error_try_again": "Попробуйте еще раз.", "error_displaying_menu": "Ошибка отображения меню.", "main_menu_unknown_action": "Неизвестное действие.", - "select_subscription_period": "Выберите срок подписки:", "subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}", - "choose_payment_method": "Выберите способ оплаты:", "pay_button": "💳 Оплатить", "pay_with_yookassa_button": "💳 ЮKassa", + "yookassa_autopay_flow_prompt": "Автопродление включено. Выберите, как оплатить подписку:", + "yookassa_autopay_pay_saved_card_button": "💳 Оплата привязанной картой", + "yookassa_autopay_pay_new_card_button": "➕ Оплата новой картой", + "yookassa_autopay_choose_saved_card": "Выберите привязанную карту для списания:", + "yookassa_autopay_no_saved_cards": "Сохранённых карт нет. Оплатите новой картой или привяжите карту в разделе «Способы оплаты».", + "back_to_autopay_method_choice_button": "⬅️ Назад", + "yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.", + "pay_with_sbp_button": "📱 СБП", + "back_to_payment_methods_button": "⬅️ Назад", "pay_with_cryptopay_button": "💎 CryptoBot", "pay_with_tribute_button": "❤️ Tribute", "pay_with_stars_button": "🌟 Звезды Telegram", "connect_button": "🔗 Подключиться", + + "devices_button": "📱 Мои устройства ({current_devices}/{max_devices})", + "my_devices_details": "📱 Список ваших устройств ({current_devices}/{max_devices})\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n
Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.
", + "no_devices_details_found_message": "📱 Список ваших устройств\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.", + "device_details": "{index}) {device_model} ({platform} {os_version})\nДата подключения: {created_at_str}\nUser Agent: {user_agent}\nHWID: {hwid}", + "disconnect_device_button": "🚫 Отключить {hwid} ({index})", + "device_disconnected": "Устройство успешно отключено.", + "no_devices_found": "Ошибка загрузки списка устройств.", + "devices_unlimited_label": "Без ограничений", + "my_devices_feature_disabled": "Раздел \"Мои устройства\" сейчас недоступен.", + "cancel_button": "❌ Отмена", "payment_description_subscription": "Оплата подписки на {months} мес.", "payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:", + "free_kassa_order_info": "Заказ №{order_id} от {date}", + "payment_invoice_sent_message": "Счёт Telegram Stars отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.", "payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.", "payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n{config_link}\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", "payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n{config_link}\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", "payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.", "config_link_not_available": "недоступна, обратитесь в поддержку", "traffic_unlimited": "Безлимитный", - "promo_code_prompt": "Пожалуйста, введите ваш промокод:", "promo_code_not_found": "Промокод {code} не найден, истек или уже использован максимальное количество раз.", "promo_code_already_used_by_user": "Вы уже активировали промокод {code}.", @@ -65,7 +85,6 @@ "referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.", "referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.", "user_is_banned": "🚫 Ваш аккаунт заблокирован. Пожалуйста, свяжитесь со службой поддержки.", - "admin_panel_title": "Панель администратора", "admin_stats_button": "📊 Статистика", "admin_broadcast_button": "📢 Рассылка", @@ -131,6 +150,7 @@ "admin_broadcast_cancelled": "Рассылка отменена.", "admin_broadcast_cancelled_alert": "Рассылка отменена!", "admin_broadcast_cancelled_nav_back": "Рассылка отменена. Вы возвращены в админ-панель.", + "broadcast_queue_result": "🚀 Рассылка поставлена в очередь!\n📤 В очередь добавлено: {sent_count}\n❌ Ошибок: {failed_count}\n\n📊 Статус очередей:\n👥 Очередь пользователей: {user_queue_size} сообщений\n📢 Очередь групп: {group_queue_size} сообщений\n\nℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram.", "admin_promo_invalid_code_format": "Код должен быть от 3 до 30 символов и содержать только буквы и цифры.", "admin_promo_invalid_bonus_days": "Количество бонусных дней должно быть положительным числом.", "admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.", @@ -321,7 +341,6 @@ "admin_user_subscription_active_until": "⏰ Действует до:", "admin_user_subscription_error": "Ошибка загрузки", "admin_promo_management_button": "🎟 Управление промокодами", - "admin_promo_management_title": "🎟 Управление промокодами\n\nВыберите промокод для детального просмотра:", "admin_promo_management_empty": "📭 Промокоды отсутствуют", "admin_promo_card_title": "🎟 Промокод: {code}", @@ -434,5 +453,6 @@ "admin_ads_delete_button": "🗑 Удалить кампанию", "admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.", "admin_ads_deleted_success": "Кампания удалена.", - "admin_ads_not_found": "Кампания не найдена." + "admin_ads_not_found": "Кампания не найдена.", + "free_kassa_order_full": "Заказ №{order_id} от {date}\n\n" }