233 lines
8.6 KiB
Python
233 lines
8.6 KiB
Python
import asyncio
|
|
import os
|
|
|
|
from datetime import datetime
|
|
|
|
import aiofiles
|
|
import pytz
|
|
|
|
from aiogram import Bot
|
|
from aiogram.exceptions import (
|
|
TelegramBadRequest,
|
|
TelegramForbiddenError,
|
|
TelegramRetryAfter,
|
|
)
|
|
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from database import create_blocked_user
|
|
from handlers.tariffs.tariff_display import get_key_tariff_display
|
|
from handlers.utils import format_hours, format_minutes, get_russian_month
|
|
from logger import logger
|
|
|
|
|
|
moscow_tz = pytz.timezone("Europe/Moscow")
|
|
|
|
|
|
async def send_messages_with_limit(
|
|
bot: Bot,
|
|
messages: list[dict],
|
|
session: AsyncSession = None,
|
|
source_file: str = None,
|
|
messages_per_second: int = 25,
|
|
):
|
|
batch_size = messages_per_second
|
|
results = []
|
|
|
|
for i in range(0, len(messages), batch_size):
|
|
batch = messages[i : i + batch_size]
|
|
tasks = [
|
|
send_notification(bot, msg["tg_id"], msg.get("photo"), msg["text"], msg.get("keyboard")) for msg in batch
|
|
]
|
|
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
for msg, result in zip(batch, batch_results, strict=False):
|
|
tg_id = msg["tg_id"]
|
|
|
|
if isinstance(result, bool) and result:
|
|
results.append(True)
|
|
elif isinstance(result, TelegramForbiddenError):
|
|
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
|
|
await try_add_blocked_user(tg_id, session, source_file)
|
|
results.append(False)
|
|
elif isinstance(result, TelegramBadRequest) and "chat not found" in str(result).lower():
|
|
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
|
|
await try_add_blocked_user(tg_id, session, source_file)
|
|
results.append(False)
|
|
else:
|
|
logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_id}.")
|
|
await try_add_blocked_user(tg_id, session, source_file)
|
|
results.append(False)
|
|
|
|
await asyncio.sleep(1.0)
|
|
|
|
return results
|
|
|
|
|
|
async def try_add_blocked_user(tg_id: int, session: AsyncSession, source_file: str | None):
|
|
if source_file == "special_notifications" and session:
|
|
try:
|
|
await create_blocked_user(session, tg_id)
|
|
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
|
|
except Exception as e:
|
|
logger.warning(f"Не удалось добавить {tg_id} в blocked_users: {e}")
|
|
|
|
|
|
def rate_limited_send(func):
|
|
async def wrapper(*args, **kwargs):
|
|
while True:
|
|
try:
|
|
return await func(*args, **kwargs)
|
|
except TelegramRetryAfter as e:
|
|
retry_in = int(e.retry_after) + 1
|
|
logger.warning(f"⚠️ Flood control: повтор через {retry_in} сек.")
|
|
await asyncio.sleep(retry_in)
|
|
except TelegramForbiddenError:
|
|
tg_id = kwargs.get("tg_id") or args[1]
|
|
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
|
|
return False
|
|
except TelegramBadRequest:
|
|
tg_id = kwargs.get("tg_id") or args[1]
|
|
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
|
|
return False
|
|
except Exception as e:
|
|
tg_id = kwargs.get("tg_id") or args[1]
|
|
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_notification(bot, tg_id, caption, keyboard)
|
|
|
|
photo_path = os.path.join("img", image_filename)
|
|
if os.path.isfile(photo_path):
|
|
return await _send_photo_notification(bot, tg_id, photo_path, image_filename, caption, keyboard)
|
|
else:
|
|
logger.warning(f"Файл с изображением не найден: {photo_path}")
|
|
return await _send_text_notification(bot, tg_id, caption, keyboard)
|
|
|
|
|
|
@rate_limited_send
|
|
async def _send_photo_notification(
|
|
bot: Bot,
|
|
tg_id: int,
|
|
photo_path: str,
|
|
image_filename: str,
|
|
caption: str,
|
|
keyboard: InlineKeyboardMarkup | None = None,
|
|
) -> bool:
|
|
try:
|
|
async with aiofiles.open(photo_path, "rb") as image_file:
|
|
image_data = await image_file.read()
|
|
buffered_photo = BufferedInputFile(image_data, filename=image_filename)
|
|
await bot.send_photo(tg_id, buffered_photo, caption=caption, reply_markup=keyboard)
|
|
return True
|
|
except (TelegramForbiddenError, TelegramBadRequest):
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Ошибка отправки фото для пользователя {tg_id}: {e}")
|
|
return await _send_text_notification(bot, tg_id, caption, keyboard)
|
|
|
|
|
|
@rate_limited_send
|
|
async def _send_text_notification(
|
|
bot: Bot,
|
|
tg_id: int,
|
|
caption: str,
|
|
keyboard: InlineKeyboardMarkup | None = None,
|
|
) -> bool:
|
|
try:
|
|
await bot.send_message(tg_id, caption, reply_markup=keyboard)
|
|
return True
|
|
except (TelegramForbiddenError, TelegramBadRequest):
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}")
|
|
return False
|
|
|
|
|
|
async def prepare_key_expiry_data(key, session: AsyncSession, 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,
|
|
}
|