487 lines
18 KiB
Python
487 lines
18 KiB
Python
import html
|
||
import os
|
||
import re
|
||
import secrets
|
||
import string
|
||
|
||
from datetime import datetime, timedelta
|
||
|
||
import aiofiles
|
||
|
||
from aiogram.exceptions import TelegramBadRequest
|
||
from aiogram.types import (
|
||
BufferedInputFile,
|
||
CallbackQuery,
|
||
InlineKeyboardMarkup,
|
||
InlineQuery,
|
||
InputMediaAnimation,
|
||
InputMediaPhoto,
|
||
InputMediaVideo,
|
||
Message,
|
||
)
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from bot import bot
|
||
from config import ADMIN_ID
|
||
from database import get_servers
|
||
from database.access.resolution import resolve_user_optional
|
||
from database.models import Key, Notification, Server
|
||
from hooks.processors import process_cluster_balancer
|
||
from logger import logger
|
||
|
||
|
||
ALLOWED_GROUP_CODES = ["trial", "discounts", "discounts_max", "gifts"]
|
||
|
||
|
||
_CALLBACK_ANSWER_IGNORE = (
|
||
"query is too old",
|
||
"response timeout expired",
|
||
"query id is invalid",
|
||
)
|
||
|
||
_MESSAGE_NOT_MODIFIED = "message is not modified"
|
||
|
||
|
||
def _is_message_not_modified(exc: BaseException) -> bool:
|
||
return isinstance(exc, TelegramBadRequest) and _MESSAGE_NOT_MODIFIED in str(exc).lower()
|
||
|
||
|
||
async def safe_answer_callback(
|
||
callback_query: CallbackQuery, text: str | None = None, show_alert: bool = False, **kwargs
|
||
) -> None:
|
||
"""
|
||
Вызывает callback_query.answer(), не поднимая исключение при устаревшем/уже отвеченном callback.
|
||
Использовать в хендлерах после долгой обработки или при наплыве пользователей.
|
||
"""
|
||
try:
|
||
await callback_query.answer(text=text, show_alert=show_alert, **kwargs)
|
||
except TelegramBadRequest as e:
|
||
msg = str(e).lower()
|
||
if not any(phrase in msg for phrase in _CALLBACK_ANSWER_IGNORE):
|
||
raise
|
||
|
||
|
||
async def safe_answer_inline_query(inline_query: InlineQuery, *args: object, **kwargs: object) -> None:
|
||
"""
|
||
Вызывает inline_query.answer(), не поднимая исключение при устаревшем запросе
|
||
(query is too old / response timeout). При нагрузке inline может обрабатываться с задержкой.
|
||
"""
|
||
try:
|
||
await inline_query.answer(*args, **kwargs)
|
||
except TelegramBadRequest as e:
|
||
msg = str(e).lower()
|
||
if not any(phrase in msg for phrase in _CALLBACK_ANSWER_IGNORE):
|
||
raise
|
||
|
||
|
||
async def generate_random_email(
|
||
length: int = 8,
|
||
session: AsyncSession | None = None,
|
||
max_attempts: int = 20,
|
||
) -> str:
|
||
alphabet = string.ascii_lowercase + string.digits
|
||
for _ in range(max_attempts):
|
||
candidate = "".join(secrets.choice(alphabet) for _ in range(length)) if length > 0 else ""
|
||
if not session:
|
||
return candidate
|
||
exists = await session.execute(select(Key.email).where(Key.email == candidate).limit(1))
|
||
if not exists.scalar_one_or_none():
|
||
return candidate
|
||
raise RuntimeError("Не удалось сгенерировать уникальный email после нескольких попыток")
|
||
|
||
|
||
async def get_least_loaded_cluster(session: AsyncSession) -> str:
|
||
"""Делегирует в services.clusters.select_cluster()."""
|
||
from services.clusters import select_cluster
|
||
|
||
result = await select_cluster(session)
|
||
return result.cluster_name
|
||
|
||
|
||
async def check_server_key_limit(server_info: dict, session: AsyncSession) -> bool:
|
||
"""Делегирует в services.clusters.check_server_key_limit() с Telegram-callback для уведомлений."""
|
||
from services.clusters import check_server_key_limit as _svc_check
|
||
|
||
async def _notify_admin_capacity(server_name: str, total_keys: int, max_keys: int) -> None:
|
||
notif_key = f"server_warn_{server_name}"
|
||
anchor_uid = None
|
||
if ADMIN_ID:
|
||
au = await resolve_user_optional(session, int(ADMIN_ID[0]))
|
||
if au is not None:
|
||
anchor_uid = au.id
|
||
already_sent = None
|
||
if anchor_uid is not None:
|
||
result = await session.execute(
|
||
select(Notification).where(
|
||
Notification.user_id == anchor_uid,
|
||
Notification.notification_type == notif_key,
|
||
)
|
||
)
|
||
already_sent = result.scalar_one_or_none()
|
||
if not already_sent:
|
||
for admin_id in ADMIN_ID:
|
||
try:
|
||
await bot.send_message(
|
||
admin_id,
|
||
f"⚠️ Сервер <b>{server_name}</b> почти заполнен ({int(total_keys / max_keys * 100)}%)."
|
||
f"\nРекомендуется создать новый для балансировки.",
|
||
)
|
||
except Exception:
|
||
pass
|
||
if anchor_uid is not None:
|
||
session.add(Notification(user_id=anchor_uid, notification_type=notif_key))
|
||
await session.commit()
|
||
|
||
return await _svc_check(server_info, session, on_capacity_warning=_notify_admin_capacity)
|
||
|
||
|
||
async def handle_error(tg_id: int, callback_query: object | None = None, message: str = "") -> None:
|
||
"""
|
||
Обрабатывает ошибку, отправляя сообщение пользователю.
|
||
"""
|
||
try:
|
||
if callback_query and hasattr(callback_query, "message"):
|
||
try:
|
||
await bot.delete_message(chat_id=tg_id, message_id=callback_query.message.message_id)
|
||
except Exception as delete_error:
|
||
logger.warning(f"Не удалось удалить сообщение: {delete_error}")
|
||
|
||
await bot.send_message(tg_id, message, parse_mode=None)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при обработке ошибки: {e}")
|
||
|
||
|
||
def get_plural_form(num: int, form1: str, form2: str, form3: str) -> str:
|
||
"""Универсальная функция для получения правильной формы множественного числа"""
|
||
n = abs(num) % 100
|
||
if 10 < n < 20:
|
||
return form3
|
||
return {1: form1, 2: form2, 3: form2, 4: form2}.get(n % 10, form3)
|
||
|
||
|
||
def format_months(months: int) -> str:
|
||
"""Форматирует количество месяцев с правильным склонением"""
|
||
if months <= 0:
|
||
return "0 месяцев"
|
||
return f"{months} {get_plural_form(months, 'месяц', 'месяца', 'месяцев')}"
|
||
|
||
|
||
def format_days(days: int) -> str:
|
||
"""
|
||
Форматирует количество дней с правильным склонением.
|
||
"""
|
||
if days <= 0:
|
||
return "0 дней"
|
||
return f"{days} {get_plural_form(days, 'день', 'дня', 'дней')}"
|
||
|
||
|
||
def format_minutes(minutes: int) -> str:
|
||
"""Форматирует количество минут с правильным склонением"""
|
||
if minutes <= 0:
|
||
return "0 минут"
|
||
return f"{minutes} {get_plural_form(minutes, 'минута', 'минуты', 'минут')}"
|
||
|
||
|
||
def format_hours(hours: int) -> str:
|
||
"""Форматирует количество часов с правильным склонением"""
|
||
if hours <= 0:
|
||
return "0 часов"
|
||
return f"{hours} {get_plural_form(hours, 'час', 'часа', 'часов')}"
|
||
|
||
|
||
def get_media_type(media_path: str) -> str:
|
||
if not media_path:
|
||
return "photo"
|
||
|
||
ext = os.path.splitext(media_path.lower())[1]
|
||
|
||
if ext in [".jpg", ".jpeg", ".png", ".webp"]:
|
||
return "photo"
|
||
|
||
if ext in [".mp4", ".mov", ".avi"]:
|
||
return "video"
|
||
|
||
if ext == ".gif":
|
||
return "animation"
|
||
|
||
return "photo"
|
||
|
||
|
||
async def edit_or_send_message(
|
||
target_message: Message,
|
||
text: str,
|
||
reply_markup: InlineKeyboardMarkup | None = None,
|
||
media_path: str = None,
|
||
disable_web_page_preview: bool = False,
|
||
force_text: bool = False,
|
||
disable_cache: bool = False,
|
||
):
|
||
if not hasattr(edit_or_send_message, "cache"):
|
||
import asyncio
|
||
|
||
from collections import OrderedDict
|
||
|
||
edit_or_send_message.cache = OrderedDict()
|
||
edit_or_send_message.lock = asyncio.Lock()
|
||
edit_or_send_message.max = 256
|
||
|
||
def find_media_file(original_path: str) -> str | None:
|
||
if not original_path:
|
||
return None
|
||
|
||
if os.path.isfile(original_path):
|
||
return original_path
|
||
|
||
base_name = os.path.splitext(original_path)[0]
|
||
supported_extensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp4", ".mov", ".avi"]
|
||
|
||
for ext in supported_extensions:
|
||
fallback_path = base_name + ext
|
||
if os.path.isfile(fallback_path):
|
||
return fallback_path
|
||
|
||
return None
|
||
|
||
if media_path:
|
||
actual_media_path = find_media_file(media_path)
|
||
if actual_media_path:
|
||
media_type = get_media_type(actual_media_path)
|
||
|
||
cached_id = None
|
||
if not disable_cache:
|
||
async with edit_or_send_message.lock:
|
||
cached_id = edit_or_send_message.cache.get(actual_media_path)
|
||
if cached_id:
|
||
edit_or_send_message.cache.move_to_end(actual_media_path)
|
||
|
||
if cached_id:
|
||
try:
|
||
if media_type == "photo":
|
||
await target_message.edit_media(
|
||
InputMediaPhoto(media=cached_id, caption=text), reply_markup=reply_markup
|
||
)
|
||
elif media_type == "video":
|
||
await target_message.edit_media(
|
||
InputMediaVideo(media=cached_id, caption=text), reply_markup=reply_markup
|
||
)
|
||
elif media_type == "animation":
|
||
await target_message.edit_media(
|
||
InputMediaAnimation(media=cached_id, caption=text), reply_markup=reply_markup
|
||
)
|
||
return
|
||
except Exception as e:
|
||
if _is_message_not_modified(e):
|
||
return
|
||
try:
|
||
if media_type == "photo":
|
||
await target_message.answer_photo(
|
||
photo=cached_id,
|
||
caption=text,
|
||
reply_markup=reply_markup,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
)
|
||
elif media_type == "video":
|
||
await target_message.answer_video(
|
||
video=cached_id,
|
||
caption=text,
|
||
reply_markup=reply_markup,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
)
|
||
elif media_type == "animation":
|
||
await target_message.answer_animation(
|
||
animation=cached_id,
|
||
caption=text,
|
||
reply_markup=reply_markup,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
)
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
async with aiofiles.open(actual_media_path, "rb") as f:
|
||
data = await f.read()
|
||
upload = BufferedInputFile(data, filename=os.path.basename(actual_media_path))
|
||
|
||
try:
|
||
if media_type == "photo":
|
||
msg = await target_message.edit_media(
|
||
InputMediaPhoto(media=upload, caption=text), reply_markup=reply_markup
|
||
)
|
||
elif media_type == "video":
|
||
msg = await target_message.edit_media(
|
||
InputMediaVideo(media=upload, caption=text), reply_markup=reply_markup
|
||
)
|
||
elif media_type == "animation":
|
||
msg = await target_message.edit_media(
|
||
InputMediaAnimation(media=upload, caption=text), reply_markup=reply_markup
|
||
)
|
||
except Exception as e:
|
||
if _is_message_not_modified(e):
|
||
return
|
||
if media_type == "photo":
|
||
msg = await target_message.answer_photo(
|
||
photo=upload,
|
||
caption=text,
|
||
reply_markup=reply_markup,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
)
|
||
elif media_type == "video":
|
||
msg = await target_message.answer_video(
|
||
video=upload,
|
||
caption=text,
|
||
reply_markup=reply_markup,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
)
|
||
elif media_type == "animation":
|
||
msg = await target_message.answer_animation(
|
||
animation=upload,
|
||
caption=text,
|
||
reply_markup=reply_markup,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
)
|
||
|
||
file_id = None
|
||
if hasattr(msg, "photo") and msg.photo:
|
||
file_id = msg.photo[-1].file_id
|
||
elif hasattr(msg, "video") and msg.video:
|
||
file_id = msg.video.file_id
|
||
elif hasattr(msg, "animation") and msg.animation:
|
||
file_id = msg.animation.file_id
|
||
|
||
if file_id and not disable_cache:
|
||
async with edit_or_send_message.lock:
|
||
if actual_media_path not in edit_or_send_message.cache:
|
||
edit_or_send_message.cache[actual_media_path] = file_id
|
||
if len(edit_or_send_message.cache) > edit_or_send_message.max:
|
||
edit_or_send_message.cache.popitem(last=False)
|
||
return
|
||
|
||
caption = getattr(target_message, "caption", None)
|
||
if not force_text and caption is not None:
|
||
try:
|
||
await target_message.edit_caption(caption=text, reply_markup=reply_markup)
|
||
return
|
||
except Exception as e:
|
||
if _is_message_not_modified(e):
|
||
return
|
||
try:
|
||
await target_message.edit_text(
|
||
text=text,
|
||
reply_markup=reply_markup,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
)
|
||
return
|
||
except Exception as e:
|
||
if _is_message_not_modified(e):
|
||
return
|
||
await target_message.answer(
|
||
text=text,
|
||
reply_markup=reply_markup,
|
||
disable_web_page_preview=disable_web_page_preview,
|
||
)
|
||
|
||
|
||
def convert_to_bytes(value: float, unit: str) -> int:
|
||
"""
|
||
Конвертирует значение с указанной единицей измерения в байты.
|
||
"""
|
||
KB = 1024
|
||
MB = KB * 1024
|
||
GB = MB * 1024
|
||
TB = GB * 1024
|
||
units = {"KB": KB, "MB": MB, "GB": GB, "TB": TB}
|
||
return int(value * units.get(unit.upper(), 1))
|
||
|
||
|
||
async def is_full_remnawave_cluster(cluster_id: str, session: AsyncSession) -> bool:
|
||
from services.clusters import is_full_remnawave_cluster as _svc
|
||
|
||
return await _svc(cluster_id, session)
|
||
|
||
|
||
def sanitize_key_name(key_name: str) -> str:
|
||
"""
|
||
Очищает название ключа, оставляя только допустимые символы.
|
||
|
||
Args:
|
||
key_name (str): Исходное название ключа.
|
||
|
||
Returns:
|
||
str: Очищенное название ключа в нижнем регистре.
|
||
"""
|
||
return re.sub(r"[^a-z0-9@._-]", "", key_name.lower())
|
||
|
||
|
||
RUSSIAN_MONTHS = {
|
||
"January": "января",
|
||
"February": "февраля",
|
||
"March": "марта",
|
||
"April": "апреля",
|
||
"May": "мая",
|
||
"June": "июня",
|
||
"July": "июля",
|
||
"August": "августа",
|
||
"September": "сентября",
|
||
"October": "октября",
|
||
"November": "ноября",
|
||
"December": "декабря",
|
||
}
|
||
|
||
|
||
def get_russian_month(date: datetime) -> str:
|
||
"""
|
||
Преобразует английское название месяца в русское.
|
||
|
||
Args:
|
||
date: Объект datetime, из которого извлекается месяц.
|
||
|
||
Returns:
|
||
Название месяца на русском языке.
|
||
"""
|
||
english_month = date.strftime("%B")
|
||
return RUSSIAN_MONTHS.get(english_month, english_month)
|
||
|
||
|
||
def get_username(user) -> str:
|
||
if getattr(user, "full_name", None):
|
||
return html.escape(user.full_name)
|
||
if getattr(user, "first_name", None):
|
||
return html.escape(user.first_name)
|
||
if getattr(user, "username", None):
|
||
return "@" + html.escape(user.username)
|
||
return "Пользователь"
|
||
|
||
|
||
def format_discount_time_left(last_time: datetime, discount_hours: int) -> str:
|
||
expires_at = last_time + timedelta(hours=discount_hours)
|
||
current_time = datetime.utcnow()
|
||
time_left = expires_at - current_time
|
||
|
||
if time_left.total_seconds() <= 0:
|
||
return "⏳ Время истекло"
|
||
|
||
total_seconds = int(time_left.total_seconds())
|
||
days = total_seconds // 86400
|
||
hours = (total_seconds % 86400) // 3600
|
||
minutes = (total_seconds % 3600) // 60
|
||
|
||
if days > 0:
|
||
return format_days(days)
|
||
elif hours > 0:
|
||
return format_hours(hours)
|
||
else:
|
||
return format_minutes(minutes)
|
||
|
||
|
||
def extract_user_data(user) -> dict:
|
||
return {
|
||
"tg_id": user.id,
|
||
"username": user.username,
|
||
"first_name": user.first_name,
|
||
"last_name": user.last_name,
|
||
"language_code": user.language_code,
|
||
"is_bot": user.is_bot,
|
||
}
|