refactor: project architecture refactor, container splitting
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
# Bot utilities package
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aiogram import types
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageContent:
|
||||
"""Класс для хранения информации о контенте сообщения"""
|
||||
|
||||
content_type: str
|
||||
file_id: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
|
||||
|
||||
# Словари поддерживаемых параметров для каждого типа сообщения
|
||||
SUPPORTED_PARAMS = {
|
||||
"text": {
|
||||
"parse_mode",
|
||||
"entities",
|
||||
"disable_web_page_preview",
|
||||
"disable_notification",
|
||||
"protect_content",
|
||||
"reply_markup",
|
||||
"reply_to_message_id",
|
||||
"allow_sending_without_reply",
|
||||
"message_thread_id",
|
||||
},
|
||||
"photo": {
|
||||
"caption",
|
||||
"parse_mode",
|
||||
"caption_entities",
|
||||
"disable_notification",
|
||||
"protect_content",
|
||||
"reply_markup",
|
||||
"reply_to_message_id",
|
||||
"allow_sending_without_reply",
|
||||
"message_thread_id",
|
||||
"has_spoiler",
|
||||
},
|
||||
"video": {
|
||||
"duration",
|
||||
"width",
|
||||
"height",
|
||||
"thumbnail",
|
||||
"caption",
|
||||
"parse_mode",
|
||||
"caption_entities",
|
||||
"supports_streaming",
|
||||
"disable_notification",
|
||||
"protect_content",
|
||||
"reply_markup",
|
||||
"reply_to_message_id",
|
||||
"allow_sending_without_reply",
|
||||
"message_thread_id",
|
||||
"has_spoiler",
|
||||
},
|
||||
"animation": {
|
||||
"duration",
|
||||
"width",
|
||||
"height",
|
||||
"thumbnail",
|
||||
"caption",
|
||||
"parse_mode",
|
||||
"caption_entities",
|
||||
"disable_notification",
|
||||
"protect_content",
|
||||
"reply_markup",
|
||||
"reply_to_message_id",
|
||||
"allow_sending_without_reply",
|
||||
"message_thread_id",
|
||||
"has_spoiler",
|
||||
},
|
||||
"document": {
|
||||
"thumbnail",
|
||||
"caption",
|
||||
"parse_mode",
|
||||
"caption_entities",
|
||||
"disable_content_type_detection",
|
||||
"disable_notification",
|
||||
"protect_content",
|
||||
"reply_markup",
|
||||
"reply_to_message_id",
|
||||
"allow_sending_without_reply",
|
||||
"message_thread_id",
|
||||
},
|
||||
"audio": {
|
||||
"caption",
|
||||
"parse_mode",
|
||||
"caption_entities",
|
||||
"duration",
|
||||
"performer",
|
||||
"title",
|
||||
"thumbnail",
|
||||
"disable_notification",
|
||||
"protect_content",
|
||||
"reply_markup",
|
||||
"reply_to_message_id",
|
||||
"allow_sending_without_reply",
|
||||
"message_thread_id",
|
||||
},
|
||||
"voice": {
|
||||
"caption",
|
||||
"parse_mode",
|
||||
"caption_entities",
|
||||
"duration",
|
||||
"disable_notification",
|
||||
"protect_content",
|
||||
"reply_markup",
|
||||
"reply_to_message_id",
|
||||
"allow_sending_without_reply",
|
||||
"message_thread_id",
|
||||
},
|
||||
"sticker": {
|
||||
"disable_notification",
|
||||
"protect_content",
|
||||
"reply_markup",
|
||||
"reply_to_message_id",
|
||||
"allow_sending_without_reply",
|
||||
"message_thread_id",
|
||||
},
|
||||
"video_note": {
|
||||
"duration",
|
||||
"length",
|
||||
"thumbnail",
|
||||
"disable_notification",
|
||||
"protect_content",
|
||||
"reply_markup",
|
||||
"reply_to_message_id",
|
||||
"allow_sending_without_reply",
|
||||
"message_thread_id",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def filter_kwargs(content_type: str, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Фильтрует kwargs, оставляя только поддерживаемые параметры для данного типа сообщения"""
|
||||
supported = SUPPORTED_PARAMS.get(content_type, set())
|
||||
return {k: v for k, v in kwargs.items() if k in supported}
|
||||
|
||||
|
||||
def get_message_content(message: types.Message) -> MessageContent:
|
||||
"""
|
||||
Определяет тип контента сообщения и возвращает его данные.
|
||||
Использует match/case вместо длинных if-elif цепочек.
|
||||
"""
|
||||
text = (message.text or message.caption or "").strip()
|
||||
|
||||
# Проверяем наличие медиа-контента
|
||||
media_content = None
|
||||
if message.photo:
|
||||
media_content = ("photo", message.photo[-1].file_id)
|
||||
elif message.video:
|
||||
media_content = ("video", message.video.file_id)
|
||||
elif message.animation:
|
||||
media_content = ("animation", message.animation.file_id)
|
||||
elif message.document:
|
||||
media_content = ("document", message.document.file_id)
|
||||
elif message.audio:
|
||||
media_content = ("audio", message.audio.file_id)
|
||||
elif message.voice:
|
||||
media_content = ("voice", message.voice.file_id)
|
||||
elif message.sticker:
|
||||
media_content = ("sticker", message.sticker.file_id)
|
||||
elif message.video_note:
|
||||
media_content = ("video_note", message.video_note.file_id)
|
||||
|
||||
# Используем match/case для определения типа контента
|
||||
match media_content:
|
||||
case (content_type, file_id):
|
||||
return MessageContent(content_type=content_type, file_id=file_id, text=text)
|
||||
case None:
|
||||
return MessageContent(content_type="text", text=text)
|
||||
case _:
|
||||
return MessageContent(content_type="text", text=text)
|
||||
|
||||
|
||||
async def send_message_by_type(bot, chat_id: int, content: MessageContent, **kwargs) -> None:
|
||||
"""
|
||||
Отправляет сообщение указанного типа.
|
||||
Использует match/case вместо длинных if-elif цепочек.
|
||||
Автоматически фильтрует неподдерживаемые параметры.
|
||||
"""
|
||||
# Фильтруем kwargs для данного типа сообщения
|
||||
filtered_kwargs = filter_kwargs(content.content_type, kwargs)
|
||||
|
||||
match content.content_type:
|
||||
case "text":
|
||||
await bot.send_message(chat_id=chat_id, text=content.text, **filtered_kwargs)
|
||||
case "photo":
|
||||
await bot.send_photo(
|
||||
chat_id=chat_id,
|
||||
photo=content.file_id,
|
||||
caption=content.text or None,
|
||||
**filtered_kwargs,
|
||||
)
|
||||
case "video":
|
||||
await bot.send_video(
|
||||
chat_id=chat_id,
|
||||
video=content.file_id,
|
||||
caption=content.text or None,
|
||||
**filtered_kwargs,
|
||||
)
|
||||
case "animation":
|
||||
await bot.send_animation(
|
||||
chat_id=chat_id,
|
||||
animation=content.file_id,
|
||||
caption=content.text or None,
|
||||
**filtered_kwargs,
|
||||
)
|
||||
case "document":
|
||||
await bot.send_document(
|
||||
chat_id=chat_id,
|
||||
document=content.file_id,
|
||||
caption=content.text or None,
|
||||
**filtered_kwargs,
|
||||
)
|
||||
case "audio":
|
||||
await bot.send_audio(
|
||||
chat_id=chat_id,
|
||||
audio=content.file_id,
|
||||
caption=content.text or None,
|
||||
**filtered_kwargs,
|
||||
)
|
||||
case "voice":
|
||||
await bot.send_voice(
|
||||
chat_id=chat_id,
|
||||
voice=content.file_id,
|
||||
caption=content.text or None,
|
||||
**filtered_kwargs,
|
||||
)
|
||||
case "sticker":
|
||||
await bot.send_sticker(chat_id=chat_id, sticker=content.file_id, **filtered_kwargs)
|
||||
case "video_note":
|
||||
await bot.send_video_note(
|
||||
chat_id=chat_id, video_note=content.file_id, **filtered_kwargs
|
||||
)
|
||||
case _:
|
||||
# Fallback для неизвестных типов - отправляем как текст
|
||||
text_kwargs = filter_kwargs("text", kwargs)
|
||||
await bot.send_message(
|
||||
chat_id=chat_id, text=content.text or "Unknown content type", **text_kwargs
|
||||
)
|
||||
|
||||
|
||||
async def send_message_via_queue(
|
||||
queue_manager, uid: int, content: MessageContent, **kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Отправляет сообщение через очередь в зависимости от типа контента.
|
||||
Использует match/case вместо длинных if-elif цепочек.
|
||||
Автоматически фильтрует неподдерживаемые параметры.
|
||||
"""
|
||||
# Фильтруем kwargs для данного типа сообщения
|
||||
filtered_kwargs = filter_kwargs(content.content_type, kwargs)
|
||||
|
||||
match content.content_type:
|
||||
case "text":
|
||||
await queue_manager.send_message(chat_id=uid, text=content.text, **filtered_kwargs)
|
||||
case "photo":
|
||||
await queue_manager.send_photo(
|
||||
chat_id=uid, photo=content.file_id, caption=content.text or None, **filtered_kwargs
|
||||
)
|
||||
case "video":
|
||||
await queue_manager.send_video(
|
||||
chat_id=uid, video=content.file_id, caption=content.text or None, **filtered_kwargs
|
||||
)
|
||||
case "animation":
|
||||
await queue_manager.send_animation(
|
||||
chat_id=uid,
|
||||
animation=content.file_id,
|
||||
caption=content.text or None,
|
||||
**filtered_kwargs,
|
||||
)
|
||||
case "document":
|
||||
await queue_manager.send_document(
|
||||
chat_id=uid,
|
||||
document=content.file_id,
|
||||
caption=content.text or None,
|
||||
**filtered_kwargs,
|
||||
)
|
||||
case "audio":
|
||||
await queue_manager.send_audio(
|
||||
chat_id=uid, audio=content.file_id, caption=content.text or None, **filtered_kwargs
|
||||
)
|
||||
case "voice":
|
||||
await queue_manager.send_voice(
|
||||
chat_id=uid, voice=content.file_id, caption=content.text or None, **filtered_kwargs
|
||||
)
|
||||
case "sticker":
|
||||
await queue_manager.send_sticker(
|
||||
chat_id=uid, sticker=content.file_id, **filtered_kwargs
|
||||
)
|
||||
case "video_note":
|
||||
await queue_manager.send_video_note(
|
||||
chat_id=uid, video_note=content.file_id, **filtered_kwargs
|
||||
)
|
||||
case _:
|
||||
# Fallback для неизвестных типов - отправляем как текст
|
||||
text_kwargs = filter_kwargs("text", kwargs)
|
||||
await queue_manager.send_message(
|
||||
chat_id=uid, text=content.text or "Unknown content type", **text_kwargs
|
||||
)
|
||||
|
||||
|
||||
async def send_direct_message(
|
||||
bot, chat_id: int, content: MessageContent, extra_text: str = "", **kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Отправляет прямое сообщение с дополнительной обработкой для sticker и video_note.
|
||||
Для этих типов медиа отправляется отдельное текстовое сообщение, т.к. они не поддерживают caption.
|
||||
Автоматически фильтрует неподдерживаемые параметры.
|
||||
""" # noqa: E501
|
||||
match content.content_type:
|
||||
case "sticker":
|
||||
# Отправляем стикер с отфильтрованными параметрами
|
||||
sticker_kwargs = filter_kwargs("sticker", kwargs)
|
||||
await bot.send_sticker(chat_id=chat_id, sticker=content.file_id, **sticker_kwargs)
|
||||
# Если есть текст с подписью, отправляем отдельно
|
||||
if content.text or extra_text:
|
||||
text_to_send = (content.text + extra_text) if content.text else extra_text
|
||||
text_kwargs = filter_kwargs("text", kwargs)
|
||||
await bot.send_message(chat_id, text_to_send, **text_kwargs)
|
||||
case "video_note":
|
||||
# Отправляем видео-заметку с отфильтрованными параметрами
|
||||
video_note_kwargs = filter_kwargs("video_note", kwargs)
|
||||
await bot.send_video_note(
|
||||
chat_id=chat_id, video_note=content.file_id, **video_note_kwargs
|
||||
)
|
||||
# Если есть текст с подписью, отправляем отдельно
|
||||
if content.text or extra_text:
|
||||
text_to_send = (content.text + extra_text) if content.text else extra_text
|
||||
text_kwargs = filter_kwargs("text", kwargs)
|
||||
await bot.send_message(chat_id, text_to_send, **text_kwargs)
|
||||
case "text":
|
||||
# Для текста объединяем с extra_text
|
||||
final_text = (content.text + extra_text) if content.text else extra_text
|
||||
text_kwargs = filter_kwargs("text", kwargs)
|
||||
await bot.send_message(chat_id=chat_id, text=final_text, **text_kwargs)
|
||||
case _:
|
||||
# Для остальных типов медиа используем caption
|
||||
final_caption = (content.text + extra_text) if content.text else None
|
||||
await send_message_by_type(
|
||||
bot,
|
||||
chat_id,
|
||||
MessageContent(content.content_type, content.file_id, final_caption),
|
||||
**kwargs,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
_EXPIRED_CALLBACK_MARKERS = (
|
||||
"query is too old",
|
||||
"response timeout expired",
|
||||
"query id is invalid",
|
||||
)
|
||||
|
||||
|
||||
def is_expired_callback_answer_error(error: BaseException) -> bool:
|
||||
if not isinstance(error, TelegramBadRequest):
|
||||
return False
|
||||
message = str(error).lower()
|
||||
return any(marker in message for marker in _EXPIRED_CALLBACK_MARKERS)
|
||||
|
||||
|
||||
async def safe_answer_callback(
|
||||
callback: CallbackQuery,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> bool:
|
||||
try:
|
||||
await callback.answer(*args, **kwargs)
|
||||
return True
|
||||
except TelegramBadRequest as error:
|
||||
user_id = getattr(getattr(callback, "from_user", None), "id", "unknown")
|
||||
if is_expired_callback_answer_error(error):
|
||||
logging.info(
|
||||
"Ignored expired callback answer for user %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
logging.warning(
|
||||
"Failed to answer callback query for user %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
except TelegramAPIError as error:
|
||||
user_id = getattr(getattr(callback, "from_user", None), "id", "unknown")
|
||||
logging.warning(
|
||||
"Telegram API error while answering callback query for user %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
@@ -0,0 +1,51 @@
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
async def _encrypt_raw_link(settings: Settings, raw_link: str) -> Optional[str]:
|
||||
"""Encrypt the raw subscription URL using the panel's happ crypt4 API."""
|
||||
async with PanelApiService(settings) as panel_service:
|
||||
encrypted_link = await panel_service.encrypt_happ_link(raw_link)
|
||||
if encrypted_link:
|
||||
return encrypted_link
|
||||
return None
|
||||
|
||||
|
||||
async def prepare_config_links(
|
||||
settings: Settings, raw_link: Optional[str]
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
Build the user-facing connection key and the URL for the connect button.
|
||||
|
||||
Returns (display_link, button_link). When CRYPT4 is enabled the display link
|
||||
is encrypted and prefixed with happ://crypt4/ by panel API, and the button link is wrapped
|
||||
with CRYPT4_REDIRECT_URL if provided.
|
||||
"""
|
||||
if not raw_link:
|
||||
return None, None
|
||||
|
||||
cleaned = raw_link.strip()
|
||||
if not cleaned:
|
||||
return None, None
|
||||
|
||||
display_link = cleaned
|
||||
button_link = cleaned
|
||||
|
||||
if settings.CRYPT4_ENABLED:
|
||||
encrypted_payload = await _encrypt_raw_link(settings, cleaned)
|
||||
if encrypted_payload:
|
||||
display_link = encrypted_payload
|
||||
button_link = display_link
|
||||
else:
|
||||
logging.error(
|
||||
"CRYPT4_ENABLED is set but encryption failed; using raw link as fallback."
|
||||
)
|
||||
|
||||
redirect_base = (settings.CRYPT4_REDIRECT_URL or "").strip()
|
||||
if redirect_base and settings.CRYPT4_ENABLED and display_link:
|
||||
button_link = f"{redirect_base}{display_link}"
|
||||
|
||||
return display_link, button_link
|
||||
@@ -0,0 +1,36 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def add_months(base_dt: datetime, months_to_add: int) -> datetime:
|
||||
"""Add calendar months to a datetime, clamping the day to the month's length.
|
||||
|
||||
Preserves tzinfo from base_dt.
|
||||
"""
|
||||
year = base_dt.year
|
||||
month = base_dt.month + months_to_add
|
||||
day = base_dt.day
|
||||
|
||||
# Normalize year and month
|
||||
year += (month - 1) // 12
|
||||
month = ((month - 1) % 12) + 1
|
||||
|
||||
# Determine last day of target month by rolling to next month's first day and subtracting 1 day
|
||||
if month == 12:
|
||||
next_month_first = datetime(year + 1, 1, 1, tzinfo=base_dt.tzinfo)
|
||||
else:
|
||||
next_month_first = datetime(year, month + 1, 1, tzinfo=base_dt.tzinfo)
|
||||
last_day = (next_month_first - timedelta(days=1)).day
|
||||
|
||||
clamped_day = min(day, last_day)
|
||||
return base_dt.replace(year=year, month=month, day=clamped_day)
|
||||
|
||||
|
||||
def month_start(base_dt: Optional[datetime] = None) -> datetime:
|
||||
"""Return the first instant of the month in UTC for a datetime."""
|
||||
moment = base_dt or datetime.now(timezone.utc)
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
moment = moment.astimezone(timezone.utc)
|
||||
return datetime(moment.year, moment.month, 1, tzinfo=timezone.utc)
|
||||
@@ -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
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Helpers for Telegram Mini App URLs (subscription webapp)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
def append_query_params(base_url: str, params: dict[str, str]) -> str:
|
||||
"""Merge params into an existing URL query string (adds or replaces keys)."""
|
||||
raw = (base_url or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
parts = urlsplit(raw)
|
||||
existing = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
for key, value in params.items():
|
||||
if value is None:
|
||||
existing.pop(key, None)
|
||||
else:
|
||||
existing[key] = str(value)
|
||||
query = urlencode(existing)
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
|
||||
|
||||
|
||||
def subscription_mini_app_topup_url(settings: Settings, kind: str) -> Optional[str]:
|
||||
"""Return Mini App URL that opens the traffic top-up flow for ``kind`` (``regular`` or ``premium``).""" # noqa: E501
|
||||
base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip()
|
||||
if not base:
|
||||
return None
|
||||
normalized = "premium" if str(kind or "").strip().lower() == "premium" else "regular"
|
||||
return append_query_params(base, {"topup": normalized})
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
def parse_ip_entries(raw_values: Optional[Sequence[str] | str]) -> list[ipaddress._BaseNetwork]:
|
||||
if raw_values is None:
|
||||
return []
|
||||
if isinstance(raw_values, str):
|
||||
values = [item.strip() for item in raw_values.split(",")]
|
||||
else:
|
||||
values = [str(item).strip() for item in raw_values]
|
||||
|
||||
parsed: list[ipaddress._BaseNetwork] = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
parsed.append(ipaddress.ip_network(value, strict=False))
|
||||
except ValueError:
|
||||
continue
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_ip(value: Optional[str]) -> Optional[ipaddress._BaseAddress]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return ipaddress.ip_address(value.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _last_forwarded_ip(header_value: str) -> Optional[str]:
|
||||
candidates = [item.strip() for item in header_value.split(",") if item.strip()]
|
||||
if not candidates:
|
||||
return None
|
||||
candidate = candidates[-1]
|
||||
return candidate if _parse_ip(candidate) is not None else None
|
||||
|
||||
|
||||
def request_client_ip(
|
||||
request: web.Request,
|
||||
*,
|
||||
trusted_proxies: Optional[Sequence[str] | str] = None,
|
||||
) -> Optional[str]:
|
||||
remote_ip = _parse_ip(request.remote or "")
|
||||
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
||||
|
||||
if remote_ip and forwarded_for:
|
||||
trusted_networks = parse_ip_entries(trusted_proxies)
|
||||
if any(remote_ip in network for network in trusted_networks):
|
||||
forwarded_ip = _last_forwarded_ip(forwarded_for)
|
||||
if forwarded_ip:
|
||||
return forwarded_ip
|
||||
|
||||
if remote_ip:
|
||||
return str(remote_ip)
|
||||
|
||||
forwarded_ip = _last_forwarded_ip(forwarded_for)
|
||||
return forwarded_ip
|
||||
|
||||
|
||||
def ip_in_allowlist(
|
||||
ip_value: Optional[str], allowed_entries: Optional[Sequence[str] | str]
|
||||
) -> bool:
|
||||
parsed_ip = _parse_ip(ip_value)
|
||||
if parsed_ip is None:
|
||||
return False
|
||||
|
||||
allowed_networks = parse_ip_entries(allowed_entries)
|
||||
return any(parsed_ip in network for network in allowed_networks)
|
||||
@@ -0,0 +1,38 @@
|
||||
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)
|
||||
@@ -0,0 +1,224 @@
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Optional
|
||||
|
||||
_OBFUSCATION_CHARS = " .\\-/\\\\•﹒٫_․·∙‧ꞏ‒–—﹘﹣⁻−"
|
||||
|
||||
_URL_PATTERNS = [
|
||||
re.compile(r"(?i)https?://\S+"),
|
||||
re.compile(r"(?i)www\.\S+"),
|
||||
re.compile(r"(?i)tg://\S+"),
|
||||
re.compile(r"(?i)telegram\.me\S*"),
|
||||
re.compile(r"(?i)t\.me/\+\S*"),
|
||||
re.compile(r"(?i)joinchat\S*"),
|
||||
]
|
||||
|
||||
_OBFUSCATED_DOMAIN_PATTERNS = [
|
||||
re.compile(
|
||||
r"(?i)[tт][\s{}\u2022]*[\.{}\u2022]*[\s{}\u2022]*[mм][eе]".format(
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
)
|
||||
),
|
||||
re.compile(
|
||||
r"(?i)[tт][{}\s]*[eе][{}\s]*[lłl1i|][{}\s]*[eе]"
|
||||
r"[{}\s]*[gɢgqг][{}\s]*[rр][{}\s]*[aа]"
|
||||
r"[{}\s]*(?:[mм]|rn)".format(
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
)
|
||||
),
|
||||
re.compile(r"(?i)t\.me\S*"),
|
||||
]
|
||||
|
||||
_ENGLISH_SERVICE_PATTERNS = [
|
||||
re.compile(r"(?i)telegram"),
|
||||
re.compile(r"(?i)teleqram"),
|
||||
re.compile(r"(?i)teiegram"),
|
||||
re.compile(r"(?i)teieqram"),
|
||||
re.compile(r"(?i)telegrarn"),
|
||||
re.compile(r"(?i)service"),
|
||||
re.compile(r"(?i)notif(?:ication)?"),
|
||||
re.compile(r"(?i)system"),
|
||||
re.compile(r"(?i)security"),
|
||||
re.compile(r"(?i)safety"),
|
||||
re.compile(r"(?i)support"),
|
||||
re.compile(r"(?i)moderation"),
|
||||
re.compile(r"(?i)review"),
|
||||
re.compile(r"(?i)compliance"),
|
||||
re.compile(r"(?i)abuse"),
|
||||
re.compile(r"(?i)spam"),
|
||||
re.compile(r"(?i)report"),
|
||||
]
|
||||
|
||||
_RUSSIAN_SERVICE_PATTERNS = [
|
||||
re.compile(r"(?i)телеграм\w*"),
|
||||
re.compile(r"(?i)служебн\w*"),
|
||||
re.compile(r"(?i)уведомлен\w*"),
|
||||
re.compile(r"(?i)поддержк\w*"),
|
||||
re.compile(r"(?i)безопасн\w*"),
|
||||
re.compile(r"(?i)модерац\w*"),
|
||||
re.compile(r"(?i)жалоб\w*"),
|
||||
re.compile(r"(?i)абуз\w*"),
|
||||
]
|
||||
|
||||
_PRE_LOWER_TRANSLATION = str.maketrans(
|
||||
{
|
||||
"I": "l",
|
||||
"İ": "l",
|
||||
"Q": "g",
|
||||
"@": " ",
|
||||
}
|
||||
)
|
||||
|
||||
_POST_LOWER_TRANSLATION = str.maketrans(
|
||||
{
|
||||
"а": "a",
|
||||
"б": "b",
|
||||
"в": "v",
|
||||
"г": "g",
|
||||
"д": "d",
|
||||
"е": "e",
|
||||
"ё": "e",
|
||||
"ж": "zh",
|
||||
"з": "z",
|
||||
"и": "i",
|
||||
"і": "i",
|
||||
"й": "i",
|
||||
"к": "k",
|
||||
"л": "l",
|
||||
"м": "m",
|
||||
"н": "n",
|
||||
"о": "o",
|
||||
"п": "p",
|
||||
"р": "r",
|
||||
"с": "s",
|
||||
"т": "t",
|
||||
"у": "u",
|
||||
"ф": "f",
|
||||
"х": "h",
|
||||
"ц": "c",
|
||||
"ч": "ch",
|
||||
"ш": "sh",
|
||||
"щ": "sh",
|
||||
"ъ": "",
|
||||
"ы": "y",
|
||||
"ь": "",
|
||||
"э": "e",
|
||||
"ю": "yu",
|
||||
"я": "ya",
|
||||
"_": "_",
|
||||
}
|
||||
)
|
||||
|
||||
_NORMALIZED_BANNED_TOKENS = {
|
||||
"tme",
|
||||
"telegram",
|
||||
"teleqram",
|
||||
"teiegram",
|
||||
"teieqram",
|
||||
"telegrarn",
|
||||
"joinchat",
|
||||
"http",
|
||||
"https",
|
||||
"www",
|
||||
"tg",
|
||||
"service",
|
||||
"notification",
|
||||
"system",
|
||||
"security",
|
||||
"safety",
|
||||
"support",
|
||||
"moderation",
|
||||
"review",
|
||||
"compliance",
|
||||
"abuse",
|
||||
"spam",
|
||||
"report",
|
||||
}
|
||||
|
||||
_USERNAME_PLACEHOLDER = "клиент"
|
||||
|
||||
|
||||
def _normalize_for_detection(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
normalized = unicodedata.normalize("NFKD", value)
|
||||
normalized = normalized.translate(_PRE_LOWER_TRANSLATION)
|
||||
normalized = normalized.lower()
|
||||
normalized = "".join(ch for ch in normalized if unicodedata.category(ch) != "Mn")
|
||||
normalized = normalized.translate(_POST_LOWER_TRANSLATION)
|
||||
normalized = normalized.replace("rn", "m")
|
||||
|
||||
pattern = rf"[{re.escape(_OBFUSCATION_CHARS)}\s]+"
|
||||
normalized = re.sub(pattern, "", normalized)
|
||||
normalized = re.sub(r"[^a-z0-9]+", "", normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def _remove_patterns(value: str) -> str:
|
||||
updated = value
|
||||
for pattern in (
|
||||
_URL_PATTERNS
|
||||
+ _OBFUSCATED_DOMAIN_PATTERNS
|
||||
+ _ENGLISH_SERVICE_PATTERNS
|
||||
+ _RUSSIAN_SERVICE_PATTERNS
|
||||
):
|
||||
updated = pattern.sub(" ", updated)
|
||||
return updated
|
||||
|
||||
|
||||
def _finalize(value: str) -> Optional[str]:
|
||||
compacted = re.sub(r"\s+", " ", value)
|
||||
compacted = compacted.strip(" \t\r\n-_.,/\\")
|
||||
compacted = compacted.strip()
|
||||
if not compacted:
|
||||
return None
|
||||
|
||||
normalized = _normalize_for_detection(compacted)
|
||||
if any(token in normalized for token in _NORMALIZED_BANNED_TOKENS):
|
||||
return None
|
||||
return compacted
|
||||
|
||||
|
||||
def sanitize_display_name(value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
clean = value.replace("@", " ")
|
||||
clean = _remove_patterns(clean)
|
||||
return _finalize(clean)
|
||||
|
||||
|
||||
def sanitize_username(value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
clean = value.strip()
|
||||
clean = clean.lstrip("@")
|
||||
clean = _remove_patterns(clean)
|
||||
return _finalize(clean)
|
||||
|
||||
|
||||
def username_for_display(username: Optional[str], with_at: bool = False) -> str:
|
||||
sanitized = sanitize_username(username)
|
||||
if not sanitized:
|
||||
return _USERNAME_PLACEHOLDER
|
||||
return f"@{sanitized}" if with_at else sanitized
|
||||
|
||||
|
||||
def display_name_or_fallback(
|
||||
first_name: Optional[str],
|
||||
fallback: Optional[str] = None,
|
||||
) -> str:
|
||||
sanitized = sanitize_display_name(first_name)
|
||||
if sanitized:
|
||||
return sanitized
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
return _USERNAME_PLACEHOLDER
|
||||
@@ -0,0 +1,77 @@
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
|
||||
|
||||
|
||||
class AsyncTTLCache:
|
||||
"""In-memory async-safe TTL cache with single-flight loader.
|
||||
|
||||
Concurrent get_or_load() calls for the same key share one loader execution.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_seconds: float, settings: Any = None, namespace: Optional[str] = None):
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.settings = settings
|
||||
self.namespace = namespace
|
||||
self._data: Dict[str, Tuple[float, Any]] = {}
|
||||
self._locks: Dict[str, asyncio.Lock] = {}
|
||||
|
||||
def _is_fresh(self, expires_at: float) -> bool:
|
||||
return time.monotonic() < expires_at
|
||||
|
||||
def get_fresh(self, key: str) -> Optional[Any]:
|
||||
entry = self._data.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
expires_at, value = entry
|
||||
if not self._is_fresh(expires_at):
|
||||
return None
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _is_cacheable(value: Any) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, dict) and value.get("error"):
|
||||
return False
|
||||
return True
|
||||
|
||||
async def get_or_load(self, key: str, loader: Callable[[], Awaitable[Any]]) -> Any:
|
||||
if self.settings is not None and self.namespace:
|
||||
try:
|
||||
from bot.infra.redis import cache_get_json, cache_set_json, redis_key
|
||||
|
||||
cache_key = redis_key(self.settings, "cache", self.namespace, key)
|
||||
cached = await cache_get_json(self.settings, cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
value = await loader()
|
||||
if self._is_cacheable(value):
|
||||
await cache_set_json(
|
||||
self.settings,
|
||||
cache_key,
|
||||
value,
|
||||
max(1, int(self.ttl_seconds)),
|
||||
)
|
||||
return value
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cached = self.get_fresh(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
lock = self._locks.setdefault(key, asyncio.Lock())
|
||||
async with lock:
|
||||
cached = self.get_fresh(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
value = await loader()
|
||||
if self._is_cacheable(value):
|
||||
self._data[key] = (time.monotonic() + self.ttl_seconds, value)
|
||||
return value
|
||||
|
||||
def invalidate(self, key: Optional[str] = None) -> None:
|
||||
if key is None:
|
||||
self._data.clear()
|
||||
return
|
||||
self._data.pop(key, None)
|
||||
Reference in New Issue
Block a user