From d503cf7f7f65b4211375f18f3bfb7789eec6915e Mon Sep 17 00:00:00 2001 From: machka pasla Date: Fri, 31 Oct 2025 21:38:41 +0300 Subject: [PATCH 1/6] YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING param added --- .env.example | 1 + README.md | 2 ++ bot/handlers/user/subscription/payments.py | 10 ++++++++-- config/settings.py | 4 ++++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 5a98439..ebe2b6a 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,7 @@ YOOKASSA_RETURN_URL=https://t.me/your_bot # YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts YOOKASSA_VAT_CODE=1 # VAT code YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle +YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox) # FreeKassa Payment Gateway Configuration FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa diff --git a/README.md b/README.md index 1849156..2528a2a 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ | `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). | | `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. | | `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | + | `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). | + | `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». | | `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). | | `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. | | `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). | diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py index 1c21005..5157552 100644 --- a/bot/handlers/user/subscription/payments.py +++ b/bot/handlers/user/subscription/payments.py @@ -408,6 +408,9 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti user_id = callback.from_user.id currency_code_for_yk = "RUB" autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + autopay_require_binding = bool( + getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) + ) saved_methods: List = [] if autopay_enabled: try: @@ -463,7 +466,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti months=months, price_rub=price_rub, currency_code_for_yk=currency_code_for_yk, - save_payment_method=autopay_enabled, + save_payment_method=autopay_enabled and autopay_require_binding, back_callback=f"subscribe_period:{months}", ) try: @@ -520,6 +523,9 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti user_id = callback.from_user.id currency_code_for_yk = "RUB" autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + autopay_require_binding = bool( + getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) + ) await _initiate_yk_payment( callback, @@ -533,7 +539,7 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti months=months, price_rub=price_rub, currency_code_for_yk=currency_code_for_yk, - save_payment_method=autopay_enabled, + save_payment_method=autopay_enabled and autopay_require_binding, back_callback=f"subscribe_period:{months}", ) try: diff --git a/config/settings.py b/config/settings.py index 561f66c..c296621 100644 --- a/config/settings.py +++ b/config/settings.py @@ -41,6 +41,10 @@ class Settings(BaseSettings): YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service") # Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew) YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False) + YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING: bool = Field( + default=True, + description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox." + ) WEBHOOK_BASE_URL: Optional[str] = None From 7df2e127a4f3a5784c54c67b9566285261aea3fc Mon Sep 17 00:00:00 2001 From: machka pasla Date: Fri, 31 Oct 2025 22:41:48 +0300 Subject: [PATCH 2/6] fix --- bot/handlers/user/payment.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 3c3ee9e..0dcfba8 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -170,6 +170,18 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, 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=True, + ) + except Exception: + logging.exception("Failed to persist multi-card YooKassa method from webhook") except Exception: logging.exception("Failed to persist YooKassa payment method from webhook") updated_payment_record = await payment_dal.update_payment_status_by_db_id( From 69cc2cbe29ff115e74e5d2a44f800bb98e41af50 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Sun, 2 Nov 2025 15:16:27 +0300 Subject: [PATCH 3/6] upd --- bot/handlers/admin/sync_admin.py | 3 ++- bot/handlers/user/subscription/core.py | 22 +++++++++++++++++++++- bot/services/referral_service.py | 2 ++ bot/services/subscription_service.py | 12 +++++++++++- db/dal/user_billing_dal.py | 16 ++++++++++++++++ locales/en.json | 1 + locales/ru.json | 1 + 7 files changed, 54 insertions(+), 3 deletions(-) diff --git a/bot/handlers/admin/sync_admin.py b/bot/handlers/admin/sync_admin.py index 39426cb..2d51c9c 100644 --- a/bot/handlers/admin/sync_admin.py +++ b/bot/handlers/admin/sync_admin.py @@ -212,6 +212,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession, "is_active": panel_status == "ACTIVE", "status_from_panel": panel_status, "traffic_limit_bytes": settings.user_traffic_limit_bytes, + "auto_renew_enabled": False, } created_sub = await subscription_dal.upsert_subscription( session, sub_payload @@ -436,4 +437,4 @@ async def sync_status_command_handler( else: response_text = _("admin_sync_status_never_run") - await message.answer(response_text, parse_mode="HTML") \ No newline at end of file + await message.answer(response_text, parse_mode="HTML") diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 783ab0c..2d6d9df 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -16,7 +16,7 @@ from bot.keyboards.inline.user_keyboards import ( from bot.services.subscription_service import SubscriptionService from bot.services.panel_api_service import PanelApiService from bot.middlewares.i18n import JsonI18n -from db.dal import subscription_dal +from db.dal import subscription_dal, user_billing_dal from db.models import Subscription router = Router(name="user_subscription_core_router") @@ -455,6 +455,14 @@ async def toggle_autorenew_handler( if sub.provider == "tribute": await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True) return + if enable: + has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id) + if not has_saved_card: + try: + await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True) + except Exception: + pass + return # Show confirmation popup and inline buttons confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable") @@ -505,6 +513,18 @@ async def confirm_autorenew_handler( if sub.provider == "tribute": await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True) return + if enable: + has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id) + if not has_saved_card: + try: + await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True) + except Exception: + pass + try: + await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot) + except Exception: + pass + return await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable}) await session.commit() diff --git a/bot/services/referral_service.py b/bot/services/referral_service.py index 9e518c8..0fcaaf7 100644 --- a/bot/services/referral_service.py +++ b/bot/services/referral_service.py @@ -177,6 +177,8 @@ class ReferralService: "ACTIVE_BONUS", "traffic_limit_bytes": self.settings.user_traffic_limit_bytes, + "auto_renew_enabled": + False, } try: await subscription_dal.deactivate_other_active_subscriptions( diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index e569b0b..2c2862e 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -498,6 +498,15 @@ class SubscriptionService: session, panel_user_uuid, panel_sub_link_id ) + auto_renew_should_enable = False + if ( + provider == "yookassa" + and getattr(self.settings, "YOOKASSA_AUTOPAYMENTS_ENABLED", False) + ): + auto_renew_should_enable = await user_billing_dal.user_has_saved_payment_method( + session, user_id + ) + sub_payload = { "user_id": user_id, "panel_user_uuid": panel_user_uuid, @@ -510,7 +519,7 @@ class SubscriptionService: "traffic_limit_bytes": self.settings.user_traffic_limit_bytes, "provider": provider, "skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS, - "auto_renew_enabled": True, + "auto_renew_enabled": auto_renew_should_enable, } try: new_or_updated_sub = await subscription_dal.upsert_subscription( @@ -616,6 +625,7 @@ class SubscriptionService: "is_active": True, "status_from_panel": "ACTIVE_BONUS", "traffic_limit_bytes": traffic_limit, + "auto_renew_enabled": False, } await subscription_dal.deactivate_other_active_subscriptions( session, panel_uuid, panel_sub_uuid diff --git a/db/dal/user_billing_dal.py b/db/dal/user_billing_dal.py index a6c1039..04e825f 100644 --- a/db/dal/user_billing_dal.py +++ b/db/dal/user_billing_dal.py @@ -162,3 +162,19 @@ async def delete_user_payment_method_by_provider_id( await session.delete(method) await session.flush() return True + + +async def user_has_saved_payment_method( + session: AsyncSession, + user_id: int, + provider: str = "yookassa", +) -> bool: + """Return True if the user has at least one saved payment method.""" + try: + methods = await list_user_payment_methods(session, user_id, provider) + if methods: + return True + billing = await get_user_billing(session, user_id) + return bool(billing and billing.yookassa_payment_method_id) + except Exception: + return False diff --git a/locales/en.json b/locales/en.json index b3ffec6..bbe5e3c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -439,6 +439,7 @@ "subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.", "subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}", "subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.", + "autorenew_enable_requires_card": "Link a payment card in Payment Methods before enabling auto-renew.", "subscription_not_active": "You don't have an active subscription.", "error_service_unavailable": "Service unavailable. Please try again later.", "error_payment_gateway": "Payment service error. Please try again later.", diff --git a/locales/ru.json b/locales/ru.json index a9e97d9..f9f8321 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -439,6 +439,7 @@ "subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.", "subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}", "subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.", + "autorenew_enable_requires_card": "Прежде чем включать автоплатёж, привяжите карту в разделе «Способы оплаты».", "subscription_not_active": "У вас нет активной подписки.", "error_service_unavailable": "Сервис недоступен. Попробуйте позже.", "error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.", From 5791f2e58cba416228610b9336bdd6b4b8be7ca9 Mon Sep 17 00:00:00 2001 From: Bogdan Strielecki Date: Mon, 3 Nov 2025 16:00:50 +0300 Subject: [PATCH 4/6] Add user profile links User profile buttons quickly opens user's telegram profile Add user profile link button to log messages Add user profile link button to user card in admin panel Add referrer profile link button to log messages Add referrer profile link button to user card in admin panel --- bot/handlers/admin/user_management.py | 61 +++++++++++++++++---- bot/keyboards/inline/admin_keyboards.py | 7 +++ bot/services/notification_service.py | 71 +++++++++++++++++++++---- locales/en.json | 6 ++- locales/ru.json | 6 ++- 5 files changed, 129 insertions(+), 22 deletions(-) diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index e0ea2d1..956dffb 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -104,7 +104,8 @@ async def user_search_prompt_handler(callback: types.CallbackQuery, await state.set_state(AdminStates.waiting_for_user_search) -def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyboardBuilder: +def get_user_card_keyboard(user_id: int, i18n_instance, lang: str, + referrer_id: Optional[int] = None) -> InlineKeyboardBuilder: """Generate keyboard for user management actions""" _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() @@ -139,13 +140,26 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb callback_data=f"user_action:refresh:{user_id}" ) - # Row 4: Destructive action + # Row 4: Quick links + builder.button( + text=_(key="user_card_open_profile_button", + default="👤 Открыть профиль"), + url=f"tg://user?id={user_id}" + ) + if referrer_id: + builder.button( + text=_(key="user_card_open_referrer_profile_button", + default="👤 Открыть профиль пригласившего"), + url=f"tg://user?id={referrer_id}" + ) + + # Row 5: Destructive action builder.button( text=_(key="admin_user_delete_button", default="❌ Удалить пользователя"), callback_data=f"user_action:delete_user:{user_id}" ) - # Row 5: Navigation + # Row 6: Navigation builder.button( text=_(key="admin_user_search_new_button", default="🔍 Найти другого"), callback_data="admin_action:users_management" @@ -155,7 +169,8 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb callback_data="admin_action:main" ) - builder.adjust(2, 2, 2, 1, 2) + quick_links_width = 2 if referrer_id else 1 + builder.adjust(2, 2, 2, quick_links_width, 1, 2) return builder @@ -315,7 +330,12 @@ async def process_user_search_handler(message: types.Message, state: FSMContext, try: referral_service = ReferralService(settings, subscription_service, message.bot, i18n) user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang, referral_service) - keyboard = get_user_card_keyboard(user_model.user_id, i18n, current_lang) + keyboard = get_user_card_keyboard( + user_model.user_id, + i18n, + current_lang, + user_model.referred_by_id + ) await message.answer( user_card_text, @@ -580,7 +600,12 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User, _settings = _Settings() referral_service = ReferralService(_settings, subscription_service, callback.message.bot, i18n_instance) user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang, referral_service) - keyboard = get_user_card_keyboard(fresh_user.user_id, i18n_instance, lang) + keyboard = get_user_card_keyboard( + fresh_user.user_id, + i18n_instance, + lang, + fresh_user.referred_by_id + ) try: await callback.message.edit_text( @@ -864,7 +889,12 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo if user: referral_service = ReferralService(settings, subscription_service, message.bot, i18n) user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service) - keyboard = get_user_card_keyboard(user.user_id, i18n, current_lang) + keyboard = get_user_card_keyboard( + user.user_id, + i18n, + current_lang, + user.referred_by_id + ) await message.answer( user_card_text, @@ -973,7 +1003,12 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte subscription_service = SubscriptionService(settings, panel_service) referral_service = ReferralService(settings, subscription_service, bot, i18n) user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang, referral_service) - keyboard = get_user_card_keyboard(target_user.user_id, i18n, current_lang) + keyboard = get_user_card_keyboard( + target_user.user_id, + i18n, + current_lang, + target_user.referred_by_id + ) await message.answer( user_card_text, @@ -1272,12 +1307,18 @@ async def user_card_from_list_handler(callback: types.CallbackQuery, return # Create keyboard with back to list button - keyboard = get_user_card_keyboard(user_id, i18n, current_lang) + keyboard = get_user_card_keyboard( + user_id, + i18n, + current_lang, + user.referred_by_id + ) keyboard.button( text=_("admin_user_back_to_list_button", default="⬅️ К списку"), callback_data=f"admin_action:users_list:{page}" ) - keyboard.adjust(2, 2, 2, 2, 1) + quick_links_width = 2 if user.referred_by_id else 1 + keyboard.adjust(2, 2, 2, quick_links_width, 1, 2, 1) # Format user card try: diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py index 91d4379..c27a3bf 100644 --- a/bot/keyboards/inline/admin_keyboards.py +++ b/bot/keyboards/inline/admin_keyboards.py @@ -384,6 +384,13 @@ def get_user_card_keyboard(user_id: int, builder.button( text=_(key="user_card_ban_button"), callback_data=f"admin_ban_confirm:{user_id}:{banned_list_page}") + builder.button( + text=_( + key="user_card_open_profile_button", + default="👤 Open profile" + ), + url=f"tg://user?id={user_id}" + ) builder.button( text=_(key="user_card_back_to_banned_list_button"), callback_data=f"admin_action:view_banned:{banned_list_page}") diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index 24f1a39..8a1e6c3 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -1,10 +1,11 @@ import logging import asyncio from aiogram import Bot +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.utils.text_decorations import html_decoration as hd from aiogram.exceptions import TelegramRetryAfter from datetime import datetime, timezone -from typing import Optional, Union, Dict, Any +from typing import Optional, Union, Dict, Any, Callable from config.settings import Settings from sqlalchemy.orm import sessionmaker @@ -34,8 +35,45 @@ class NotificationService: if username: base_display = f"{base_display} ({username_for_display(username)})" return base_display + + @staticmethod + def _build_profile_keyboard( + translate: Callable[..., str], + user_id: int, + referrer_id: Optional[int] = None, + ) -> InlineKeyboardMarkup: + """Create inline keyboard with links to user (and referrer) profiles.""" + buttons = [ + [ + InlineKeyboardButton( + text=translate( + "log_open_profile_link", + default="👤 Открыть профиль", + ), + url=f"tg://user?id={user_id}", + ) + ] + ] + + if referrer_id: + buttons.append([ + InlineKeyboardButton( + text=translate( + "log_open_referrer_profile_button", + default="👤 Открыть профиль пригласившего", + ), + url=f"tg://user?id={referrer_id}", + ) + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) - async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None): + async def _send_to_log_channel( + self, + message: str, + thread_id: Optional[int] = None, + reply_markup: Optional[InlineKeyboardMarkup] = None, + ): """Send message to configured log channel/group using message queue""" if not self.settings.LOG_CHAT_ID: return @@ -49,6 +87,7 @@ class NotificationService: text=message, parse_mode="HTML", disable_web_page_preview=True, + reply_markup=reply_markup, message_thread_id=thread_id or self.settings.LOG_THREAD_ID ) except Exception as e: @@ -64,6 +103,8 @@ class NotificationService: "parse_mode": "HTML", "disable_web_page_preview": True } + if reply_markup: + kwargs["reply_markup"] = reply_markup # Add thread ID for supergroups if specified if final_thread_id: @@ -124,7 +165,12 @@ class NotificationService: referral_text = "" if referred_by_id: - referral_text = _("log_referral_suffix", default=" (реферал от {referrer_id})", referrer_id=referred_by_id) + referrer_link = hd.link(str(referred_by_id), f"tg://user?id={referred_by_id}") + referral_text = _( + "log_referral_suffix", + default=" (реферал от {referrer_link})", + referrer_link=referrer_link, + ) message = _( "log_new_user_registration", @@ -137,9 +183,10 @@ class NotificationService: referral_text=referral_text, timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") ) - + # Send to log channel - await self._send_to_log_channel(message) + profile_keyboard = self._build_profile_keyboard(_, user_id, referred_by_id) + await self._send_to_log_channel(message, reply_markup=profile_keyboard) async def notify_payment_received(self, user_id: int, amount: float, currency: str, months: int, payment_provider: str, @@ -182,7 +229,8 @@ class NotificationService: ) # Send to log channel - await self._send_to_log_channel(message) + profile_keyboard = self._build_profile_keyboard(_, user_id) + await self._send_to_log_channel(message, reply_markup=profile_keyboard) async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int, username: Optional[str] = None): @@ -212,7 +260,8 @@ class NotificationService: ) # Send to log channel - await self._send_to_log_channel(message) + profile_keyboard = self._build_profile_keyboard(_, user_id) + await self._send_to_log_channel(message, reply_markup=profile_keyboard) async def notify_trial_activation(self, user_id: int, end_date: datetime, username: Optional[str] = None): @@ -240,7 +289,8 @@ class NotificationService: ) # Send to log channel - await self._send_to_log_channel(message) + profile_keyboard = self._build_profile_keyboard(_, user_id) + await self._send_to_log_channel(message, reply_markup=profile_keyboard) async def notify_panel_sync(self, status: str, details: str, users_processed: int, subs_synced: int, @@ -275,7 +325,7 @@ class NotificationService: details=details ) - # Send to log channel + # Send to log channel await self._send_to_log_channel(message) async def notify_suspicious_promo_attempt( @@ -308,7 +358,8 @@ class NotificationService: timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z")) # Send to log channel - await self._send_to_log_channel(message) + profile_keyboard = self._build_profile_keyboard(_, user_id) + await self._send_to_log_channel(message, reply_markup=profile_keyboard) async def send_custom_notification(self, message: str, to_admins: bool = False, to_log_channel: bool = True, thread_id: Optional[int] = None): diff --git a/locales/en.json b/locales/en.json index b3ffec6..e2d6354 100644 --- a/locales/en.json +++ b/locales/en.json @@ -210,6 +210,8 @@ "admin_user_card_title": "User Card", "user_card_ban_button": "🚫 Ban", "user_card_unban_button": "✅ Unban", + "user_card_open_profile_button": "👤 Open profile", + "user_card_open_referrer_profile_button": "👤 Referrer profile", "user_card_back_to_banned_list_button": "⬅️ Back to Ban List", "admin_logs_menu_title": "Logs Menu:", "admin_view_all_logs_button": "📜 All Message Logs", @@ -291,7 +293,9 @@ "inline_admin_financial_stats_title": "💰 Financial Statistics", "inline_system_stats_message": "🖥 Panel Statistics\n\n🟢 Online: {online}\n📊 Active: {active}\n🔴 Disabled: {disabled}\n⏰ Expired: {expired}\n⚠️ Limited: {limited}\n👥 Total users: {total}\n💾 RAM Usage: {memory:.1f}%\n📊 Week traffic: {week_traffic}\n📊 Month traffic: {month_traffic}\n🔗 Active nodes: {active_nodes}/{total_nodes}", "inline_admin_system_stats_title": "🖥 System Statistics", - "log_referral_suffix": " (referral from {referrer_id})", + "log_referral_suffix": " (referral from {referrer_link})", + "log_open_profile_link": "👤 Open profile", + "log_open_referrer_profile_button": "👤 Referrer profile", "log_new_user_registration": "👤 New User\n\n🆔 ID: {user_id}\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}", "log_payment_received": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n📅 Period: {months} mo.\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", "log_promo_activation": "🎁 Promo Code Activated\n\n👤 User: {user_display}\n🏷 Code: {promo_code}\n🎯 Bonus: +{bonus_days}d\n🕐 Time: {timestamp}", diff --git a/locales/ru.json b/locales/ru.json index a9e97d9..ba55685 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -220,6 +220,8 @@ "admin_user_card_title": "Карточка пользователя", "user_card_ban_button": "🚫 Заблокировать", "user_card_unban_button": "✅ Разблокировать", + "user_card_open_profile_button": "👤 Открыть профиль", + "user_card_open_referrer_profile_button": "👤 Профиль пригласившего", "user_card_back_to_banned_list_button": "⬅️ К списку забаненных", "admin_logs_menu_title": "Меню логов:", "admin_view_all_logs_button": "📜 Все логи сообщений", @@ -291,7 +293,9 @@ "inline_admin_financial_stats_title": "💰 Финансовая статистика", "inline_system_stats_message": "🖥 Статистика панели\n\n🟢 Онлайн: {online}\n📊 Активных: {active}\n🔴 Отключенных: {disabled}\n⏰ Истекшие: {expired}\n⚠️ Ограниченные: {limited}\n👥 Всего пользователей: {total}\n💾 Использование RAM: {memory:.1f}%\n📊 Трафик за неделю: {week_traffic}\n📊 Трафик за месяц: {month_traffic}\n🔗 Активных нод: {active_nodes}/{total_nodes}", "inline_admin_system_stats_title": "🖥 Системная статистика", - "log_referral_suffix": " (реферал от {referrer_id})", + "log_referral_suffix": " (реферал от {referrer_link})", + "log_open_profile_link": "👤 Открыть профиль", + "log_open_referrer_profile_button": "👤 Профиль пригласившего", "log_new_user_registration": "👤 Новый пользователь\n\n🆔 ID: {user_id}\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}", "log_payment_received": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n📅 Период: {months} мес.\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", "log_promo_activation": "🎁 Активирован промокод\n\n👤 Пользователь: {user_display}\n🏷 Код: {promo_code}\n🎯 Бонус: +{bonus_days} дн.\n🕐 Время: {timestamp}", From 7d446d5f64c093fe9f5823c4cbddd6c87be9d178 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Thu, 6 Nov 2025 19:03:19 +0300 Subject: [PATCH 5/6] upd promo bug --- bot/handlers/user/start.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index ae4f4e0..ebfc2ed 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -357,6 +357,17 @@ async def start_command_handler(message: types.Message, db_user, created = await user_dal.create_user(session, user_data_to_create) if created: + try: + await session.commit() + except Exception as commit_error: + await session.rollback() + logging.error( + f"Failed to commit new user {user_id}: {commit_error}", + exc_info=True, + ) + await message.answer(_("error_occurred_processing_request")) + return + logging.info( f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}." ) From 643bf5fabb587aead36e2a6f78980cdef1b8be87 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Fri, 7 Nov 2025 20:47:09 +0300 Subject: [PATCH 6/6] fix bug --- bot/handlers/admin/user_management.py | 102 +++++++++++++++++++++----- 1 file changed, 83 insertions(+), 19 deletions(-) diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index 956dffb..3431b8f 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -4,7 +4,7 @@ from aiogram import Router, F, types, Bot from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.utils.markdown import hcode, hbold -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, Callable, Awaitable from sqlalchemy.ext.asyncio import AsyncSession from datetime import datetime, timezone @@ -174,6 +174,56 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str, return builder +def _remove_profile_link_buttons( + markup: Optional[types.InlineKeyboardMarkup]) -> Optional[types.InlineKeyboardMarkup]: + """Drop buttons that rely on tg://user links to avoid BUTTON_USER_INVALID errors.""" + if not markup or not markup.inline_keyboard: + return None + + cleaned_rows = [] + for row in markup.inline_keyboard: + filtered_row = [ + button for button in row + if not (getattr(button, "url", None) and button.url.startswith("tg://user?id=")) + ] + if filtered_row: + cleaned_rows.append(filtered_row) + + if not cleaned_rows: + return None + + return types.InlineKeyboardMarkup(inline_keyboard=cleaned_rows) + + +async def _send_with_profile_link_fallback( + sender: Callable[..., Awaitable[Any]], + *, + text: str, + markup: Optional[types.InlineKeyboardMarkup], + user_id: int, + parse_mode: Optional[str] = "HTML") -> None: + """Send text with markup and fallback if Telegram rejects tg://user buttons.""" + send_kwargs: Dict[str, Any] = {"text": text, "reply_markup": markup} + if parse_mode is not None: + send_kwargs["parse_mode"] = parse_mode + + try: + await sender(**send_kwargs) + except TelegramBadRequest as exc: + message = getattr(exc, "message", "") or str(exc) + if "BUTTON_USER_INVALID" not in message: + raise + + logging.warning( + "Telegram rejected profile buttons for user %s: %s. Retrying without tg:// links.", + user_id, + message, + ) + fallback_markup = _remove_profile_link_buttons(markup) + send_kwargs["reply_markup"] = fallback_markup + await sender(**send_kwargs) + + async def format_user_card(user: User, session: AsyncSession, subscription_service: SubscriptionService, i18n_instance, lang: str, @@ -337,9 +387,11 @@ async def process_user_search_handler(message: types.Message, state: FSMContext, user_model.referred_by_id ) - await message.answer( - user_card_text, - reply_markup=keyboard.as_markup(), + await _send_with_profile_link_fallback( + message.answer, + text=user_card_text, + markup=keyboard.as_markup(), + user_id=user_model.user_id, parse_mode="HTML" ) except Exception as e: @@ -606,17 +658,22 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User, lang, fresh_user.referred_by_id ) + markup = keyboard.as_markup() try: - await callback.message.edit_text( - user_card_text, - reply_markup=keyboard.as_markup(), + await _send_with_profile_link_fallback( + callback.message.edit_text, + text=user_card_text, + markup=markup, + user_id=fresh_user.user_id, parse_mode="HTML" ) except Exception: - await callback.message.answer( - user_card_text, - reply_markup=keyboard.as_markup(), + await _send_with_profile_link_fallback( + callback.message.answer, + text=user_card_text, + markup=markup, + user_id=fresh_user.user_id, parse_mode="HTML" ) @@ -896,9 +953,11 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo user.referred_by_id ) - await message.answer( - user_card_text, - reply_markup=keyboard.as_markup(), + await _send_with_profile_link_fallback( + message.answer, + text=user_card_text, + markup=keyboard.as_markup(), + user_id=user.user_id, parse_mode="HTML" ) else: @@ -1010,9 +1069,11 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte target_user.referred_by_id ) - await message.answer( - user_card_text, - reply_markup=keyboard.as_markup(), + await _send_with_profile_link_fallback( + message.answer, + text=user_card_text, + markup=keyboard.as_markup(), + user_id=target_user.user_id, parse_mode="HTML" ) @@ -1325,10 +1386,13 @@ async def user_card_from_list_handler(callback: types.CallbackQuery, from bot.services.referral_service import ReferralService referral_service = ReferralService(settings, subscription_service, bot, i18n) user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service) + markup = keyboard.as_markup() - await callback.message.edit_text( - user_card_text, - reply_markup=keyboard.as_markup(), + await _send_with_profile_link_fallback( + callback.message.edit_text, + text=user_card_text, + markup=markup, + user_id=user.user_id, parse_mode="HTML" ) await callback.answer()