refactor: project architecture refactor, container splitting

This commit is contained in:
3252a8
2026-05-17 00:01:28 +03:00
parent e0b5218037
commit 30fb774d93
367 changed files with 2609 additions and 864 deletions
+350
View File
@@ -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,
)
+51
View File
@@ -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
+51
View File
@@ -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
+36
View File
@@ -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)
+299
View File
@@ -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
+33
View File
@@ -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})
+75
View File
@@ -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)
+38
View File
@@ -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)
+224
View File
@@ -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
+77
View File
@@ -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)