Files
Solo_bot/handlers/utils.py
T
2026-04-14 07:19:14 +00:00

487 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}