diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index 3431b8f..07f092d 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -24,6 +24,10 @@ from bot.utils.text_sanitizer import ( sanitize_username, username_for_display, ) +from bot.utils.telegram_markup import ( + is_profile_link_error, + remove_profile_link_buttons, +) router = Router(name="admin_user_management_router") USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$") @@ -174,27 +178,6 @@ 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]], *, @@ -210,16 +193,15 @@ async def _send_with_profile_link_fallback( try: await sender(**send_kwargs) except TelegramBadRequest as exc: - message = getattr(exc, "message", "") or str(exc) - if "BUTTON_USER_INVALID" not in message: + if not is_profile_link_error(exc): raise logging.warning( "Telegram rejected profile buttons for user %s: %s. Retrying without tg:// links.", user_id, - message, + getattr(exc, "message", "") or str(exc), ) - fallback_markup = _remove_profile_link_buttons(markup) + fallback_markup = remove_profile_link_buttons(markup) send_kwargs["reply_markup"] = fallback_markup await sender(**send_kwargs) diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index 8a1e6c3..f2d6db5 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -3,7 +3,7 @@ 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 aiogram.exceptions import TelegramBadRequest from datetime import datetime, timezone from typing import Optional, Union, Dict, Any, Callable @@ -15,6 +15,10 @@ from bot.utils.text_sanitizer import ( display_name_or_fallback, username_for_display, ) +from bot.utils.telegram_markup import ( + is_profile_link_error, + remove_profile_link_buttons, +) class NotificationService: @@ -81,14 +85,42 @@ class NotificationService: queue_manager = get_queue_manager() if not queue_manager: logging.warning("Message queue manager not available, falling back to direct send") + final_thread_id = thread_id or self.settings.LOG_THREAD_ID + + def _build_kwargs(markup: Optional[InlineKeyboardMarkup]) -> Dict[str, Any]: + kwargs: Dict[str, Any] = { + "chat_id": self.settings.LOG_CHAT_ID, + "text": message, + "parse_mode": "HTML", + "disable_web_page_preview": True, + } + if markup: + kwargs["reply_markup"] = markup + if final_thread_id: + kwargs["message_thread_id"] = final_thread_id + return kwargs + try: - await self.bot.send_message( - chat_id=self.settings.LOG_CHAT_ID, - 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 + await self.bot.send_message(**_build_kwargs(reply_markup)) + except TelegramBadRequest as exc: + if is_profile_link_error(exc): + fallback_markup = remove_profile_link_buttons(reply_markup) + logging.warning( + "Telegram rejected profile buttons for log chat %s: %s. " + "Retrying without tg:// links.", + self.settings.LOG_CHAT_ID, + getattr(exc, "message", "") or str(exc), + ) + try: + await self.bot.send_message(**_build_kwargs(fallback_markup)) + except Exception as retry_exc: + logging.error( + "Failed to send notification without profile buttons to log " + f"channel {self.settings.LOG_CHAT_ID}: {retry_exc}" + ) + return + logging.error( + f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {exc}" ) except Exception as e: logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}") diff --git a/bot/utils/message_queue.py b/bot/utils/message_queue.py index 0f9314c..47fe979 100644 --- a/bot/utils/message_queue.py +++ b/bot/utils/message_queue.py @@ -5,6 +5,12 @@ from dataclasses import dataclass from datetime import datetime, timedelta from collections import deque from aiogram import Bot +from aiogram.exceptions import TelegramBadRequest + +from bot.utils.telegram_markup import ( + is_profile_link_error, + remove_profile_link_buttons, +) @dataclass @@ -51,14 +57,31 @@ class MessageQueue: message = self.queue.popleft() try: await self._send_message(message) - self.last_send_times.append(datetime.now()) - self.total_sent += 1 + self._record_send_time() + + except TelegramBadRequest as exc: + fallback_message = self._build_profile_link_fallback(message, exc) + if fallback_message: + logging.warning( + "Telegram rejected profile buttons for chat %s: %s. " + "Retrying without tg:// links.", + message.chat_id, + getattr(exc, "message", "") or str(exc), + ) + try: + await self._send_message(fallback_message) + self._record_send_time() + continue + except Exception as retry_exc: + self.total_failed += 1 + logging.error( + f"Failed to send fallback message to {message.chat_id}: {retry_exc}" + ) + continue + + self.total_failed += 1 + logging.error(f"Failed to send queued message to {message.chat_id}: {exc}") - # Keep only recent send times (last minute) - cutoff_time = datetime.now() - timedelta(seconds=60) - while self.last_send_times and self.last_send_times[0] < cutoff_time: - 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}") @@ -77,6 +100,38 @@ class MessageQueue: if time_since_last < self.delay_between_messages: wait_time = self.delay_between_messages - time_since_last await asyncio.sleep(wait_time) + + def _record_send_time(self) -> None: + """Track sent message timestamps and purge old entries for rate limiting.""" + now = datetime.now() + self.last_send_times.append(now) + self.total_sent += 1 + + cutoff_time = now - timedelta(seconds=60) + while self.last_send_times and self.last_send_times[0] < cutoff_time: + self.last_send_times.popleft() + + def _build_profile_link_fallback( + self, message: QueuedMessage, exc: Exception + ) -> Optional[QueuedMessage]: + """Create a fallback message without tg://user buttons when Telegram rejects them.""" + if not is_profile_link_error(exc): + return None + + markup = message.kwargs.get("reply_markup") + if markup is None: + return None + + safe_markup = remove_profile_link_buttons(markup) + fallback_kwargs = dict(message.kwargs) + fallback_kwargs["reply_markup"] = safe_markup + + return QueuedMessage( + chat_id=message.chat_id, + method_name=message.method_name, + kwargs=fallback_kwargs, + callback=message.callback, + ) async def _send_message(self, message: QueuedMessage) -> Any: """Send a single message - to be implemented by subclass""" diff --git a/bot/utils/telegram_markup.py b/bot/utils/telegram_markup.py new file mode 100644 index 0000000..04aaddf --- /dev/null +++ b/bot/utils/telegram_markup.py @@ -0,0 +1,39 @@ +from typing import Optional + +from aiogram import types + +PROFILE_BUTTON_ERROR_CODES = ("BUTTON_USER_INVALID", "BUTTON_USER_PRIVACY_RESTRICTED") +TG_USER_LINK_PREFIX = "tg://user?id=" + + +def remove_profile_link_buttons( + markup: Optional[types.InlineKeyboardMarkup], +) -> Optional[types.InlineKeyboardMarkup]: + """Remove buttons that point to tg://user links to avoid privacy-related errors.""" + inline_keyboard = getattr(markup, "inline_keyboard", None) + if not markup or not inline_keyboard: + return None + + cleaned_rows = [] + for row in inline_keyboard: + filtered_row = [ + button + for button in row + if not ( + getattr(button, "url", None) + and str(button.url).startswith(TG_USER_LINK_PREFIX) + ) + ] + if filtered_row: + cleaned_rows.append(filtered_row) + + if not cleaned_rows: + return None + + return types.InlineKeyboardMarkup(inline_keyboard=cleaned_rows) + + +def is_profile_link_error(exc: BaseException) -> bool: + """Return True if Telegram rejected markup because of profile link buttons.""" + message = getattr(exc, "message", "") or str(exc) + return any(code in message for code in PROFILE_BUTTON_ERROR_CODES)