refactor: project architecture refactor, container splitting
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from bot.utils.telegram_markup import (
|
||||
is_profile_link_error,
|
||||
remove_profile_link_buttons,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueuedMessage:
|
||||
"""Represents a queued message with all necessary parameters"""
|
||||
|
||||
chat_id: int
|
||||
method_name: str # 'send_message', 'edit_message_text', etc.
|
||||
kwargs: Dict[str, Any]
|
||||
callback: Optional[Callable[[Any], Awaitable[None]]] = None # Optional callback for result
|
||||
|
||||
|
||||
class MessageQueue:
|
||||
"""Message queue with rate limiting for Telegram API"""
|
||||
|
||||
# Telegram allows ~1 message/sec to the same chat before returning 429.
|
||||
PER_CHAT_MIN_INTERVAL_SECONDS = 1.0
|
||||
# Drop per-chat timestamps older than this to keep the dict bounded.
|
||||
PER_CHAT_TTL_SECONDS = 5.0
|
||||
|
||||
def __init__(self, messages_per_second: float, burst_size: int = 5):
|
||||
self.messages_per_second = messages_per_second
|
||||
self.burst_size = burst_size
|
||||
self.queue: deque[QueuedMessage] = deque()
|
||||
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
|
||||
self._chat_last_sent: Dict[int, datetime] = {}
|
||||
|
||||
async def add_message(self, message: QueuedMessage) -> None:
|
||||
"""Add message to queue"""
|
||||
self.queue.append(message)
|
||||
if not self.is_processing:
|
||||
asyncio.create_task(self._process_queue())
|
||||
|
||||
async def _process_queue(self) -> None:
|
||||
"""Process messages from queue with rate limiting"""
|
||||
if self.is_processing:
|
||||
return
|
||||
|
||||
self.is_processing = True
|
||||
|
||||
try:
|
||||
while self.queue:
|
||||
# Peek at the head to honor per-chat throttling before popping.
|
||||
message = self.queue[0]
|
||||
await self._wait_if_needed(message.chat_id)
|
||||
self.queue.popleft()
|
||||
|
||||
try:
|
||||
await self._send_message(message)
|
||||
self._record_send_time(message.chat_id)
|
||||
|
||||
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(message.chat_id)
|
||||
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}")
|
||||
|
||||
except Exception:
|
||||
self.total_failed += 1
|
||||
logging.exception("Failed to send queued message to %s.", message.chat_id)
|
||||
|
||||
finally:
|
||||
self.is_processing = False
|
||||
|
||||
async def _wait_if_needed(self, chat_id: Optional[int] = None) -> None:
|
||||
"""Wait if we need to respect global and per-chat rate limits."""
|
||||
now = datetime.now()
|
||||
waits: list[float] = []
|
||||
|
||||
if self.last_send_times:
|
||||
time_since_last = (now - self.last_send_times[-1]).total_seconds()
|
||||
if time_since_last < self.delay_between_messages:
|
||||
waits.append(self.delay_between_messages - time_since_last)
|
||||
|
||||
if chat_id is not None:
|
||||
last_chat = self._chat_last_sent.get(chat_id)
|
||||
if last_chat is not None:
|
||||
time_since_chat = (now - last_chat).total_seconds()
|
||||
if time_since_chat < self.PER_CHAT_MIN_INTERVAL_SECONDS:
|
||||
waits.append(self.PER_CHAT_MIN_INTERVAL_SECONDS - time_since_chat)
|
||||
|
||||
if waits:
|
||||
await asyncio.sleep(max(waits))
|
||||
|
||||
def _record_send_time(self, chat_id: Optional[int] = None) -> 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()
|
||||
|
||||
if chat_id is not None:
|
||||
self._chat_last_sent[chat_id] = now
|
||||
chat_cutoff = now - timedelta(seconds=self.PER_CHAT_TTL_SECONDS)
|
||||
stale = [cid for cid, ts in self._chat_last_sent.items() if ts < chat_cutoff]
|
||||
for cid in stale:
|
||||
self._chat_last_sent.pop(cid, None)
|
||||
|
||||
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"""
|
||||
raise NotImplementedError("Subclass must implement _send_message")
|
||||
|
||||
|
||||
class TelegramMessageQueue(MessageQueue):
|
||||
"""Telegram-specific message queue"""
|
||||
|
||||
def __init__(self, bot: Bot, messages_per_second: float, burst_size: int = 5):
|
||||
super().__init__(messages_per_second, burst_size)
|
||||
self.bot = bot
|
||||
|
||||
async def _send_message(self, message: QueuedMessage) -> Any:
|
||||
"""Send message using bot method"""
|
||||
method = getattr(self.bot, message.method_name)
|
||||
result = await method(chat_id=message.chat_id, **message.kwargs)
|
||||
|
||||
# Call callback if provided
|
||||
if message.callback:
|
||||
await message.callback(result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class MessageQueueManager:
|
||||
"""Manager for different types of message queues"""
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
|
||||
# Different queues for different types of chats
|
||||
self.group_queue = TelegramMessageQueue(
|
||||
bot=bot,
|
||||
messages_per_second=15 / 60, # 15 messages per minute for groups
|
||||
burst_size=3,
|
||||
)
|
||||
|
||||
self.user_queue = TelegramMessageQueue(
|
||||
bot=bot,
|
||||
messages_per_second=25, # 25 messages per second for users
|
||||
burst_size=10,
|
||||
)
|
||||
|
||||
def _is_group_chat(self, chat_id: int) -> bool:
|
||||
"""Check if chat_id belongs to a group or channel"""
|
||||
return str(chat_id).startswith("-100")
|
||||
|
||||
async def send_message(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_message call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(chat_id=chat_id, method_name="send_message", kwargs=kwargs)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def edit_message_text(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue an edit_message_text call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(chat_id=chat_id, method_name="edit_message_text", kwargs=kwargs)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_document(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_document call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(chat_id=chat_id, method_name="send_document", kwargs=kwargs)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_photo(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_photo call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(chat_id=chat_id, method_name="send_photo", kwargs=kwargs)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_video(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_video call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(chat_id=chat_id, method_name="send_video", kwargs=kwargs)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_animation(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_animation (GIF) call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(chat_id=chat_id, method_name="send_animation", kwargs=kwargs)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_audio(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_audio call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(chat_id=chat_id, method_name="send_audio", kwargs=kwargs)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_voice(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_voice call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(chat_id=chat_id, method_name="send_voice", kwargs=kwargs)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_sticker(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_sticker call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(chat_id=chat_id, method_name="send_sticker", kwargs=kwargs)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_video_note(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_video_note call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(chat_id=chat_id, method_name="send_video_note", kwargs=kwargs)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def answer_callback_query(self, callback_query_id: str, **kwargs) -> None:
|
||||
"""Send callback query answer immediately (not rate limited)"""
|
||||
await self.bot.answer_callback_query(callback_query_id, **kwargs)
|
||||
|
||||
def get_queue_stats(self) -> Dict[str, Any]:
|
||||
"""Get statistics about queues"""
|
||||
return {
|
||||
"group_queue_size": len(self.group_queue.queue),
|
||||
"user_queue_size": len(self.user_queue.queue),
|
||||
"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),
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
# Global queue manager instance
|
||||
_queue_manager: Optional[MessageQueueManager] = None
|
||||
|
||||
|
||||
def init_queue_manager(bot: Bot) -> MessageQueueManager:
|
||||
"""Initialize global queue manager"""
|
||||
global _queue_manager
|
||||
_queue_manager = MessageQueueManager(bot)
|
||||
return _queue_manager
|
||||
|
||||
|
||||
def get_queue_manager() -> Optional[MessageQueueManager]:
|
||||
"""Get global queue manager instance"""
|
||||
return _queue_manager
|
||||
Reference in New Issue
Block a user