From e414ae40d42ffb0c763b76872bf2b1efa526910f Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 08:40:04 +0300 Subject: [PATCH] Handle unreachable users in monitoring notifications --- app/services/monitoring_service.py | 205 ++++++++++++++++++++++++----- locales/en.json | 3 + locales/ru.json | 3 + 3 files changed, 180 insertions(+), 31 deletions(-) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 337e18f8..9225be24 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -2,37 +2,46 @@ import asyncio import logging from datetime import datetime, timedelta from typing import Dict, List, Any, Optional, Set -from sqlalchemy.ext.asyncio import AsyncSession + +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from sqlalchemy import select, and_, or_ +from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.config import settings from app.database.database import get_db -from app.database.crud.subscription import ( - get_expired_subscriptions, get_expiring_subscriptions, - get_subscriptions_for_autopay, deactivate_subscription, - extend_subscription -) -from app.database.crud.user import ( - get_user_by_id, get_inactive_users, delete_user, - subtract_user_balance +from app.database.crud.discount_offer import ( + deactivate_expired_offers, + upsert_discount_offer, ) from app.database.crud.notification import ( notification_sent, record_notification, ) -from app.database.crud.discount_offer import ( - upsert_discount_offer, - deactivate_expired_offers, +from app.database.crud.subscription import ( + deactivate_subscription, + extend_subscription, + get_expired_subscriptions, + get_expiring_subscriptions, + get_subscriptions_for_autopay, +) +from app.database.crud.user import ( + delete_user, + get_inactive_users, + get_user_by_id, + subtract_user_balance, ) from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus -from app.services.subscription_service import SubscriptionService -from app.services.payment_service import PaymentService from app.localization.texts import get_texts from app.services.notification_settings_service import NotificationSettingsService +from app.services.payment_service import PaymentService +from app.services.subscription_service import SubscriptionService from app.external.remnawave_api import ( - RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError + RemnaWaveAPIError, + RemnaWaveUser, + TrafficLimitStrategy, + UserStatus, ) logger = logging.getLogger(__name__) @@ -45,9 +54,43 @@ class MonitoringService: self.subscription_service = SubscriptionService() self.payment_service = PaymentService() self.bot = bot - self._notified_users: Set[str] = set() + self._notified_users: Set[str] = set() self._last_cleanup = datetime.utcnow() self._sla_task = None + + @staticmethod + def _is_unreachable_error(error: TelegramBadRequest) -> bool: + message = str(error).lower() + unreachable_markers = ( + "chat not found", + "user is deactivated", + "bot was blocked by the user", + "bot can't initiate conversation", + "can't initiate conversation", + "user not found", + "peer id invalid", + ) + return any(marker in message for marker in unreachable_markers) + + def _handle_unreachable_user(self, user: User, error: Exception, context: str) -> bool: + if isinstance(error, TelegramForbiddenError): + logger.warning( + "⚠️ Пользователь %s недоступен (%s): бот заблокирован", + user.telegram_id, + context, + ) + return True + + if isinstance(error, TelegramBadRequest) and self._is_unreachable_error(error): + logger.warning( + "⚠️ Пользователь %s недоступен (%s): %s", + user.telegram_id, + context, + error, + ) + return True + + return False async def start_monitoring(self): if self.is_running: @@ -619,9 +662,22 @@ class MonitoringService: reply_markup=keyboard ) return True - + + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "уведомление об истечении подписки"): + return True + logger.error( + "Ошибка Telegram API при отправке уведомления об истечении подписки пользователю %s: %s", + user.telegram_id, + exc, + ) + return False except Exception as e: - logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки уведомления об истечении подписки пользователю %s: %s", + user.telegram_id, + e, + ) return False async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool: @@ -663,9 +719,22 @@ class MonitoringService: reply_markup=keyboard ) return True - + + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "уведомление об истекающей подписке"): + return True + logger.error( + "Ошибка Telegram API при отправке уведомления об истечении подписки пользователю %s: %s", + user.telegram_id, + exc, + ) + return False except Exception as e: - logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки уведомления об истечении подписки пользователю %s: %s", + user.telegram_id, + e, + ) return False async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool: @@ -703,9 +772,22 @@ class MonitoringService: reply_markup=keyboard ) return True - + + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "уведомление о завершении тестовой подписки"): + return True + logger.error( + "Ошибка Telegram API при отправке уведомления о завершении тестовой подписки пользователю %s: %s", + user.telegram_id, + exc, + ) + return False except Exception as e: - logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки уведомления об окончании тестовой подписки пользователю %s: %s", + user.telegram_id, + e, + ) return False async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, hours: int) -> bool: @@ -750,8 +832,21 @@ class MonitoringService: ) return True + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "уведомление о бездействии на тесте"): + return True + logger.error( + "Ошибка Telegram API при отправке уведомления об отсутствии подключения пользователю %s: %s", + user.telegram_id, + exc, + ) + return False except Exception as e: - logger.error(f"Ошибка отправки уведомления об отсутствии подключения пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки уведомления об отсутствии подключения пользователю %s: %s", + user.telegram_id, + e, + ) return False async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool: @@ -785,8 +880,21 @@ class MonitoringService: ) return True + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "напоминание об истекшей подписке"): + return True + logger.error( + "Ошибка Telegram API при отправке напоминания об истекшей подписке пользователю %s: %s", + user.telegram_id, + exc, + ) + return False except Exception as e: - logger.error(f"Ошибка отправки напоминания об истекшей подписке пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки напоминания об истекшей подписке пользователю %s: %s", + user.telegram_id, + e, + ) return False async def _send_expired_discount_notification( @@ -846,8 +954,21 @@ class MonitoringService: ) return True + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "скидочное уведомление"): + return True + logger.error( + "Ошибка Telegram API при отправке скидочного уведомления пользователю %s: %s", + user.telegram_id, + exc, + ) + return False except Exception as e: - logger.error(f"Ошибка отправки скидочного уведомления пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки скидочного уведомления пользователю %s: %s", + user.telegram_id, + e, + ) return False async def _send_autopay_success_notification(self, user: User, amount: int, days: int): @@ -858,9 +979,20 @@ class MonitoringService: amount=settings.format_price(amount) ) await self.bot.send_message(user.telegram_id, message, parse_mode="HTML") + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if not self._handle_unreachable_user(user, exc, "уведомление об успешном автоплатеже"): + logger.error( + "Ошибка Telegram API при отправке уведомления об автоплатеже пользователю %s: %s", + user.telegram_id, + exc, + ) except Exception as e: - logger.error(f"Ошибка отправки уведомления об автоплатеже пользователю {user.telegram_id}: {e}") - + logger.error( + "Ошибка отправки уведомления об автоплатеже пользователю %s: %s", + user.telegram_id, + e, + ) + async def _send_autopay_failed_notification(self, user: User, balance: int, required: int): try: texts = get_texts(user.language) @@ -877,14 +1009,25 @@ class MonitoringService: ]) await self.bot.send_message( - user.telegram_id, - message, + user.telegram_id, + message, parse_mode="HTML", reply_markup=keyboard ) - + + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if not self._handle_unreachable_user(user, exc, "уведомление о неудачном автоплатеже"): + logger.error( + "Ошибка Telegram API при отправке уведомления о неудачном автоплатеже пользователю %s: %s", + user.telegram_id, + exc, + ) except Exception as e: - logger.error(f"Ошибка отправки уведомления о неудачном автоплатеже пользователю {user.telegram_id}: {e}") + logger.error( + "Ошибка отправки уведомления о неудачном автоплатеже пользователю %s: %s", + user.telegram_id, + e, + ) async def _cleanup_inactive_users(self, db: AsyncSession): try: diff --git a/locales/en.json b/locales/en.json index 2f370239..bb1b7fc8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -107,6 +107,7 @@ "SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!", "SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!", "SUBSCRIPTION_ACTIVE": "✅ Active", + "SUBSCRIPTION_EXTEND": "💎 Extend subscription", "SUCCESS": "✅ Success", "REGISTRATION_COMPLETING": "✅ Completing registration...", "SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic", @@ -147,6 +148,7 @@ "CREATE_TICKET_BUTTON": "🎫 Create ticket", "MY_TICKETS_BUTTON": "📋 My tickets", "CONTACT_SUPPORT_BUTTON": "💬 Contact support", + "SUPPORT_BUTTON": "🆘 Support", "TICKET_PRIORITY_SELECT": "Select ticket priority:", "TICKET_PRIORITY_LOW": "🟢 Low", "TICKET_PRIORITY_NORMAL": "🟡 Normal", @@ -272,6 +274,7 @@ "BALANCE_INFO": "\n💰 Balance: {balance}\n\nChoose an action:\n", "BALANCE_SUPPORT_REQUEST": "🛠️ Request via support", "BALANCE_TOP_UP": "💳 Top up", + "BALANCE_TOPUP": "💳 Top up balance", "CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.", "CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!", "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!", diff --git a/locales/ru.json b/locales/ru.json index 5a9218f1..ff9fa404 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -24,6 +24,7 @@ "CREATE_TICKET_BUTTON": "🎫 Создать тикет", "MY_TICKETS_BUTTON": "📋 Мои тикеты", "CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой", + "SUPPORT_BUTTON": "🆘 Поддержка", "TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:", "TICKET_PRIORITY_LOW": "🟢 Низкий", "TICKET_PRIORITY_NORMAL": "🟡 Обычный", @@ -154,6 +155,7 @@ "BALANCE_INFO": "\n💰 Баланс: {balance}\n\nВыберите действие:\n", "BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку", "BALANCE_TOP_UP": "💳 Пополнить", + "BALANCE_TOPUP": "💳 Пополнить баланс", "CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.", "CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!", "CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!", @@ -298,6 +300,7 @@ "SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки", "SKIP_BUTTON": "⏭️ Пропустить", "SUBSCRIPTION_ACTIVE": "✅ Активна", + "SUBSCRIPTION_EXTEND": "💎 Продлить подписку", "SUBSCRIPTION_EXPIRED": "\n❌ Подписка истекла\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n", "SUBSCRIPTION_EXPIRING": "\n⚠️ Подписка истекает!\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n", "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Подписка истекает через {days_text}!\n\nВаша платная подписка истекает {end_date}.\n\n💳 Автоплатеж: {autopay_status}\n\n{action_text}\n",