Files

432 lines
16 KiB
Python

from __future__ import annotations
import asyncio
import os
import time
from collections import OrderedDict, deque
import aiofiles
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
from datetime import datetime
import pytz
from database import async_session_maker
from database.bans import save_blocked_user_ids
from handlers.utils import format_hours, format_minutes, get_russian_month
from logger import logger
from services.tariffs.tariff_display import get_key_tariff_display
from utils.custom_emojis import _process_text
moscow_tz = pytz.timezone("Europe/Moscow")
_photo_cache: OrderedDict[str, str] = OrderedDict()
_photo_cache_lock = asyncio.Lock()
_PHOTO_CACHE_MAX = 64
_SUPPORTED_EXTENSIONS = (".jpg", ".jpeg", ".png", ".webp", ".gif")
async def _get_cached_file_id(photo_path: str) -> str | None:
async with _photo_cache_lock:
fid = _photo_cache.get(photo_path)
if fid:
_photo_cache.move_to_end(photo_path)
return fid
async def _set_cached_file_id(photo_path: str, file_id: str) -> None:
async with _photo_cache_lock:
if photo_path not in _photo_cache:
_photo_cache[photo_path] = file_id
if len(_photo_cache) > _PHOTO_CACHE_MAX:
_photo_cache.popitem(last=False)
def _find_photo_file(photo_path: str) -> str | None:
if os.path.isfile(photo_path):
return photo_path
base_name = os.path.splitext(photo_path)[0]
for ext in _SUPPORTED_EXTENSIONS:
candidate = base_name + ext
if os.path.isfile(candidate):
return candidate
return None
class NotificationRateLimiter:
def __init__(self, max_rate: int = 35, window: float = 1.0) -> None:
self.max_rate = max_rate
self.window = window
self.send_times: deque = deque()
self.lock = asyncio.Lock()
async def acquire(self):
async with self.lock:
while True:
now = time.time()
cutoff = now - self.window
while self.send_times and self.send_times[0] <= cutoff:
self.send_times.popleft()
if len(self.send_times) < self.max_rate:
self.send_times.append(now)
return
time_to_wait = (self.send_times[0] + self.window) - now
if time_to_wait > 0:
await asyncio.sleep(time_to_wait + 0.001)
def rate_limited_send(func):
async def wrapper(*args, **kwargs):
while True:
try:
return await func(*args, **kwargs)
except TelegramRetryAfter as e:
await asyncio.sleep(int(e.retry_after) + 1)
except TelegramForbiddenError:
return False
except TelegramBadRequest:
return False
except Exception as e:
tg_id = kwargs.get("tg_id") or (args[1] if len(args) > 1 else "?")
logger.error(f"Ошибка отправки пользователю {tg_id}: {e}")
return False
return wrapper
async def send_notification(
bot: Bot,
tg_id: int,
image_filename: str | None,
caption: str,
keyboard: InlineKeyboardMarkup | None = None,
) -> bool:
if image_filename is None:
return await _send_text(bot, tg_id, caption, keyboard)
photo_path = os.path.join("img", image_filename)
cached_id = await _get_cached_file_id(photo_path)
if cached_id:
return await _send_photo(bot, tg_id, photo_path, image_filename, caption, keyboard, cached_id)
actual_path = _find_photo_file(photo_path)
if actual_path:
return await _send_photo(bot, tg_id, actual_path, image_filename, caption, keyboard)
else:
logger.warning(f"Файл изображения не найден: {photo_path}")
return await _send_text(bot, tg_id, caption, keyboard)
@rate_limited_send
async def _send_photo(
bot: Bot,
tg_id: int,
photo_path: str,
image_filename: str,
caption: str,
keyboard: InlineKeyboardMarkup | None = None,
cached_file_id: str | None = None,
) -> bool:
try:
if cached_file_id:
await bot.send_photo(tg_id, cached_file_id, caption=caption, reply_markup=keyboard)
return True
async with aiofiles.open(photo_path, "rb") as f:
image_data = await f.read()
buffered = BufferedInputFile(image_data, filename=image_filename)
result = await bot.send_photo(tg_id, buffered, caption=caption, reply_markup=keyboard)
if result and hasattr(result, "photo") and result.photo:
await _set_cached_file_id(os.path.join("img", image_filename), result.photo[-1].file_id)
return True
except (TelegramForbiddenError, TelegramBadRequest):
return False
except Exception as e:
logger.error(f"Ошибка отправки фото пользователю {tg_id}: {e}")
return await _send_text(bot, tg_id, caption, keyboard)
@rate_limited_send
async def _send_text(
bot: Bot,
tg_id: int,
caption: str,
keyboard: InlineKeyboardMarkup | None = None,
) -> bool:
try:
processed, entities = await _process_text(caption)
kwargs = {"reply_markup": keyboard}
if entities:
kwargs["entities"] = entities
kwargs["parse_mode"] = None
await bot.send_message(tg_id, processed, **kwargs)
return True
except (TelegramForbiddenError, TelegramBadRequest):
return False
except Exception as e:
logger.error(f"Ошибка отправки текста пользователю {tg_id}: {e}")
return False
_NOTIFY_MAX_ATTEMPTS = 5
_NOTIFY_MAX_RETRY_AFTER = 120.0
class FastNotificationSender:
def __init__(self, bot: Bot, messages_per_second: int = 35, max_attempts: int = _NOTIFY_MAX_ATTEMPTS) -> None:
self.bot = bot
self.rate_limiter = NotificationRateLimiter(max_rate=messages_per_second)
self.max_attempts = max_attempts
self.blocked_users: set[int] = set()
self.queue: asyncio.Queue = asyncio.Queue()
self.results: list[bool] = []
self.total_sent = 0
self.pending_retries = 0
self.is_running = False
async def _send_one(self, msg: dict) -> str:
tg_id = msg["tg_id"]
try:
await self.rate_limiter.acquire()
processed_text, entities = await _process_text(msg["text"])
emoji_kwargs = {}
if entities:
emoji_kwargs["parse_mode"] = None
if msg.get("photo"):
photo_path = os.path.join("img", msg["photo"])
cached_id = await _get_cached_file_id(photo_path)
caption_kwargs = {"caption_entities": entities} if entities else {}
if cached_id:
await self.bot.send_photo(
chat_id=tg_id, photo=cached_id,
caption=processed_text, reply_markup=msg.get("keyboard"),
**emoji_kwargs, **caption_kwargs,
)
else:
actual_path = _find_photo_file(photo_path)
if actual_path:
async with aiofiles.open(actual_path, "rb") as f:
image_data = await f.read()
buffered = BufferedInputFile(image_data, filename=os.path.basename(actual_path))
result = await self.bot.send_photo(
chat_id=tg_id, photo=buffered,
caption=processed_text, reply_markup=msg.get("keyboard"),
**emoji_kwargs, **caption_kwargs,
)
if result and hasattr(result, "photo") and result.photo:
await _set_cached_file_id(photo_path, result.photo[-1].file_id)
else:
text_kwargs = {"entities": entities} if entities else {}
await self.bot.send_message(
chat_id=tg_id, text=processed_text, reply_markup=msg.get("keyboard"),
**emoji_kwargs, **text_kwargs,
)
else:
text_kwargs = {"entities": entities} if entities else {}
await self.bot.send_message(
chat_id=tg_id, text=processed_text, reply_markup=msg.get("keyboard"),
**emoji_kwargs, **text_kwargs,
)
return "ok"
except TelegramRetryAfter as e:
wait_seconds = min(float(e.retry_after), _NOTIFY_MAX_RETRY_AFTER)
msg["_attempts"] = msg.get("_attempts", 0) + 1
msg["_retry_at"] = time.time() + wait_seconds
return "retry"
except TelegramForbiddenError:
self.blocked_users.add(tg_id)
return "fail"
except TelegramBadRequest as e:
if "chat not found" in str(e).lower():
self.blocked_users.add(tg_id)
return "fail"
except Exception:
return "fail"
async def _schedule_retry(self, msg: dict) -> None:
try:
wait = msg.get("_retry_at", 0.0) - time.time()
if wait > 0:
await asyncio.sleep(wait)
await self.queue.put(msg)
except asyncio.CancelledError:
self.results.append(False)
raise
except Exception:
self.results.append(False)
finally:
self.pending_retries -= 1
async def _worker(self):
while True:
try:
msg = await asyncio.wait_for(self.queue.get(), timeout=0.5)
except (TimeoutError, asyncio.TimeoutError):
if not self.is_running:
return
continue
try:
result = await self._send_one(msg)
if result == "ok":
self.total_sent += 1
self.results.append(True)
elif result == "retry":
if msg.get("_attempts", 0) < self.max_attempts:
try:
asyncio.create_task(self._schedule_retry(msg))
self.pending_retries += 1
except Exception as e:
logger.error(
f"Не удалось запланировать повтор для {msg.get('tg_id')}: {e}"
)
self.results.append(False)
else:
self.results.append(False)
else:
self.results.append(False)
except Exception as e:
logger.error(f"Ошибка в воркере уведомлений: {e}")
self.results.append(False)
finally:
self.queue.task_done()
async def _save_blocked_users(self):
if not self.blocked_users:
return
try:
async with async_session_maker() as session:
await save_blocked_user_ids(session, list(self.blocked_users))
await session.commit()
logger.info(f"Добавлено до {len(self.blocked_users)} пользователей в blocked_users")
except Exception as e:
logger.error(f"Ошибка сохранения заблокированных: {e}")
async def send_all(self, messages: list[dict], workers: int = 15) -> list[bool]:
if not messages:
return []
self.is_running = True
self.results = []
self.total_sent = 0
self.blocked_users = set()
self.pending_retries = 0
start = time.time()
for msg in messages:
await self.queue.put(msg)
worker_tasks = [asyncio.create_task(self._worker()) for _ in range(workers)]
while True:
await self.queue.join()
if self.pending_retries <= 0:
break
await asyncio.sleep(0.5)
self.is_running = False
for task in worker_tasks:
task.cancel()
await asyncio.gather(*worker_tasks, return_exceptions=True)
await self._save_blocked_users()
duration = time.time() - start
speed = self.total_sent / duration if duration > 0 else 0
logger.info(f"Уведомления: {self.total_sent}/{len(messages)} за {duration:.1f}s ({speed:.1f} msg/s)")
return self.results
async def send_messages_with_limit(
bot: Bot,
messages: list[dict],
messages_per_second: int = 35,
) -> list[bool]:
sender = FastNotificationSender(bot, messages_per_second)
return await sender.send_all(messages)
async def prepare_key_expiry_data(key, session, current_time: int) -> dict:
if isinstance(key, dict):
expiry_timestamp = key.get("expiry_time")
email = key.get("email") or ""
record = dict(key)
else:
expiry_timestamp = getattr(key, "expiry_time", None)
email = getattr(key, "email", "") or ""
record = {
"tariff_id": getattr(key, "tariff_id", None),
"server_id": getattr(key, "server_id", None),
"client_id": getattr(key, "client_id", None),
"selected_device_limit": getattr(key, "selected_device_limit", None),
"selected_traffic_limit": getattr(key, "selected_traffic_limit", None),
}
if not expiry_timestamp:
return {
"hours_left_formatted": "",
"formatted_expiry_date": "",
"tariff_name": "",
"tariff_details": "",
}
delta_ms = max(0, expiry_timestamp - current_time)
total_minutes = delta_ms // (60 * 1000)
hours_left = total_minutes // 60
minutes_left = total_minutes % 60
if hours_left > 0 or minutes_left > 0:
parts = []
if hours_left > 0:
parts.append(format_hours(hours_left))
if minutes_left > 0:
parts.append(format_minutes(minutes_left))
hours_left_formatted = f"⏳ Осталось времени: {' '.join(parts)}"
else:
hours_left_formatted = "⏳ Последний день подписки!"
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
month_name = get_russian_month(expiry_datetime)
formatted_expiry_date = expiry_datetime.strftime(f"%d {month_name} %Y, %H:%M (МСК)")
tariff_name = ""
subgroup_title = ""
traffic_limit_gb = 0
device_limit = 0
try:
name, subgroup_title, traffic_limit_gb, device_limit, _, _ = await get_key_tariff_display(
session=session,
key_record=record,
)
if name:
tariff_name = name
except Exception as error:
logger.warning(f"[NOTIFY] Ошибка тарифных лимитов для {email}: {error}")
traffic_text = "безлимит" if traffic_limit_gb == 0 else f"{traffic_limit_gb} ГБ"
devices_text = "безлимит" if device_limit == 0 else str(device_limit)
lines = []
if subgroup_title:
lines.append(subgroup_title)
lines.append(f"Трафик: {traffic_text}")
lines.append(f"Устройств: {devices_text}")
tariff_details = "\n" + "\n".join(lines) if lines else ""
return {
"hours_left_formatted": hours_left_formatted,
"formatted_expiry_date": formatted_expiry_date,
"tariff_name": tariff_name,
"tariff_details": tariff_details,
}