refactoring/notify_optimization/least_loaded_cluster for key_limit/rm args
This commit is contained in:
+57
-380
@@ -35,7 +35,6 @@ async def get_temporary_data(session, tg_id: int) -> dict | None:
|
||||
|
||||
|
||||
async def clear_temporary_data(session, tg_id: int):
|
||||
# TODO rename delete_temporary_data
|
||||
await session.execute("DELETE FROM temporary_data WHERE tg_id = $1", tg_id)
|
||||
|
||||
|
||||
@@ -49,9 +48,6 @@ async def create_blocked_user(tg_id: int, conn: asyncpg.Connection):
|
||||
async def delete_blocked_user(tg_id: int | list[int], conn: asyncpg.Connection):
|
||||
"""
|
||||
Удаляет пользователя или список пользователей из списка заблокированных.
|
||||
|
||||
:param tg_id: ID пользователя Telegram или список ID
|
||||
:param conn: Подключение к базе данных
|
||||
"""
|
||||
if isinstance(tg_id, list):
|
||||
await conn.execute("DELETE FROM blocked_users WHERE tg_id = ANY($1)", tg_id)
|
||||
@@ -77,11 +73,6 @@ async def init_db(file_path: str = "assets/schema.sql"):
|
||||
async def check_unique_server_name(server_name: str, session: Any, cluster_name: str | None = None) -> bool:
|
||||
"""
|
||||
Проверяет уникальность имени сервера.
|
||||
|
||||
:param server_name: Имя сервера.
|
||||
:param session: Сессия базы данных.
|
||||
:param cluster_name: Имя кластера (опционально).
|
||||
:return: True, если имя сервера уникально, False, если уже существует.
|
||||
"""
|
||||
if cluster_name:
|
||||
result = await session.fetchrow(
|
||||
@@ -96,14 +87,6 @@ async def check_unique_server_name(server_name: str, session: Any, cluster_name:
|
||||
async def check_server_name_by_cluster(server_name: str, session: Any) -> dict | None:
|
||||
"""
|
||||
Проверяет принадлежность сервера к кластеру.
|
||||
|
||||
Args:
|
||||
server_name (str): Имя сервера для проверки
|
||||
session (Any): Сессия базы данных
|
||||
|
||||
Returns:
|
||||
dict | None: Словарь с информацией о кластере или None, если сервер не найден
|
||||
- cluster_name (str): Название кластера
|
||||
"""
|
||||
try:
|
||||
cluster_info = await session.fetchrow(
|
||||
@@ -127,20 +110,6 @@ async def check_server_name_by_cluster(server_name: str, session: Any) -> dict |
|
||||
async def create_coupon(coupon_code: str, amount: int, usage_limit: int, session: Any, days: int = None):
|
||||
"""
|
||||
Создает новый купон в базе данных.
|
||||
|
||||
Args:
|
||||
coupon_code (str): Уникальный код купона.
|
||||
amount (int): Сумма, которую дает купон (0 для купонов на дни).
|
||||
usage_limit (int): Максимальное количество использований купона.
|
||||
session (Any): Сессия базы данных для выполнения запроса.
|
||||
days (int, optional): Количество дней для продления подписки.
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при создании купона.
|
||||
|
||||
Example:
|
||||
await create_coupon('SALE50', 50, 5, session)
|
||||
await create_coupon('DAYS10', 0, 50, session, days=10)
|
||||
"""
|
||||
try:
|
||||
await session.execute(
|
||||
@@ -162,22 +131,6 @@ async def create_coupon(coupon_code: str, amount: int, usage_limit: int, session
|
||||
async def get_coupon_by_code(coupon_code: str, session: Any) -> dict | None:
|
||||
"""
|
||||
Получает информацию о купоне по его коду.
|
||||
|
||||
Args:
|
||||
coupon_code (str): Код купона для поиска
|
||||
session (Any): Сессия базы данных
|
||||
|
||||
Returns:
|
||||
dict | None: Словарь с информацией о купоне или None, если купон не найден
|
||||
- id (int): ID купона
|
||||
- usage_limit (int): Лимит использований
|
||||
- usage_count (int): Текущее количество использований
|
||||
- is_used (bool): Флаг использования
|
||||
- amount (int): Сумма купона
|
||||
- days (int): Количество дней (если есть)
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при выполнении запроса
|
||||
"""
|
||||
try:
|
||||
result = await session.fetchrow(
|
||||
@@ -197,21 +150,6 @@ async def get_coupon_by_code(coupon_code: str, session: Any) -> dict | None:
|
||||
async def get_all_coupons(session: Any, page: int = 1, per_page: int = 10):
|
||||
"""
|
||||
Получает список купонов из базы данных с пагинацией.
|
||||
|
||||
Args:
|
||||
session (Any): Сессия базы данных для выполнения запроса
|
||||
page (int): Номер страницы (по умолчанию 1)
|
||||
per_page (int): Количество купонов на странице (по умолчанию 10)
|
||||
|
||||
Returns:
|
||||
dict: Словарь с информацией о купонах и пагинации:
|
||||
- coupons (list): Список словарей с информацией о купонах
|
||||
- total (int): Общее количество купонов
|
||||
- pages (int): Общее количество страниц
|
||||
- current_page (int): Текущая страница
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при получении данных из базы
|
||||
"""
|
||||
try:
|
||||
offset = (page - 1) * per_page
|
||||
@@ -238,19 +176,6 @@ async def get_all_coupons(session: Any, page: int = 1, per_page: int = 10):
|
||||
async def delete_coupon(coupon_code: str, session: Any):
|
||||
"""
|
||||
Удаляет купон из базы данных по его коду.
|
||||
|
||||
Args:
|
||||
coupon_code (str): Код купона для удаления
|
||||
session (Any): Сессия базы данных для выполнения запроса
|
||||
|
||||
Returns:
|
||||
bool: True, если купон успешно удален, False если купон не найден или произошла ошибка
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при выполнении запроса к базе данных
|
||||
|
||||
Example:
|
||||
result = await delete_coupon('SALE50', session)
|
||||
"""
|
||||
try:
|
||||
coupon_record = await session.fetchrow(
|
||||
@@ -282,14 +207,6 @@ async def delete_coupon(coupon_code: str, session: Any):
|
||||
async def update_trial(tg_id: int, status: int, session: Any):
|
||||
"""
|
||||
Устанавливает статус триального периода для пользователя.
|
||||
|
||||
Args:
|
||||
tg_id (int): Telegram ID пользователя
|
||||
status (int): Статус триального периода (0 - доступен, 1 - использован)
|
||||
session (Any): Сессия базы данных
|
||||
|
||||
Returns:
|
||||
bool: True, если статус успешно установлен, False в случае ошибки
|
||||
"""
|
||||
try:
|
||||
await session.execute(
|
||||
@@ -318,11 +235,6 @@ async def add_user(
|
||||
):
|
||||
"""
|
||||
Добавляет нового пользователя в таблицу users.
|
||||
|
||||
Args:
|
||||
tg_id (int): Telegram ID
|
||||
session (Any): Сессия базы данных
|
||||
... остальные поля из Telegram профиля
|
||||
"""
|
||||
try:
|
||||
await session.execute(
|
||||
@@ -342,12 +254,6 @@ async def add_user(
|
||||
async def check_user_exists(tg_id: int) -> bool:
|
||||
"""
|
||||
Проверяет существование пользователя в таблице users.
|
||||
|
||||
Args:
|
||||
tg_id (int): Telegram ID
|
||||
|
||||
Returns:
|
||||
bool: True, если пользователь найден, иначе False
|
||||
"""
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
@@ -418,15 +324,6 @@ async def get_clusters(session) -> list[str]:
|
||||
async def get_keys(tg_id: int, session: Any):
|
||||
"""
|
||||
Получает список ключей для указанного пользователя.
|
||||
|
||||
Args:
|
||||
tg_id (int): Telegram ID пользователя
|
||||
|
||||
Returns:
|
||||
list: Список записей ключей с информацией о клиенте, электронной почте, времени создания и ключе
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при подключении к базе данных или выполнении запроса
|
||||
"""
|
||||
try:
|
||||
records = await session.fetch(
|
||||
@@ -444,52 +341,6 @@ async def get_keys(tg_id: int, session: Any):
|
||||
raise
|
||||
|
||||
|
||||
async def get_keys_by_server(tg_id: int | None, server_id: str, session: Any):
|
||||
"""
|
||||
Получает список ключей на определенном сервере. Если tg_id=None, возвращает все ключи на сервере.
|
||||
|
||||
Args:
|
||||
tg_id (int | None): Telegram ID пользователя или None для всех пользователей
|
||||
server_id (str): Идентификатор сервера
|
||||
|
||||
Returns:
|
||||
list: Список записей ключей с информацией о клиенте, электронной почте, времени создания и ключе
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при подключении к базе данных или выполнении запроса
|
||||
"""
|
||||
try:
|
||||
if tg_id is not None:
|
||||
records = await session.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM keys
|
||||
WHERE tg_id = $1 AND server_id = $2
|
||||
""",
|
||||
tg_id,
|
||||
server_id,
|
||||
)
|
||||
logger.info(f"Успешно получено {len(records)} ключей для пользователя {tg_id} на сервере {server_id}")
|
||||
else:
|
||||
records = await session.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM keys
|
||||
WHERE server_id = $1
|
||||
""",
|
||||
server_id,
|
||||
)
|
||||
logger.info(f"Успешно получено {len(records)} ключей на сервере {server_id}")
|
||||
|
||||
return records
|
||||
except Exception as e:
|
||||
error_msg = f"Ошибка при получении ключей на сервере {server_id}"
|
||||
if tg_id is not None:
|
||||
error_msg += f" для пользователя {tg_id}"
|
||||
logger.error(f"{error_msg}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def get_key_by_server(tg_id: int, client_id: str, session: Any):
|
||||
query = """
|
||||
SELECT
|
||||
@@ -512,15 +363,6 @@ async def get_key_by_server(tg_id: int, client_id: str, session: Any):
|
||||
async def get_balance(tg_id: int) -> float:
|
||||
"""
|
||||
Получает баланс пользователя из базы данных.
|
||||
|
||||
Args:
|
||||
tg_id (int): Telegram ID пользователя
|
||||
|
||||
Returns:
|
||||
float: Баланс пользователя, 0.0 если баланс не найден
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при подключении к базе данных или выполнении запроса
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
@@ -593,13 +435,6 @@ async def update_balance(
|
||||
async def get_trial(tg_id: int, session: Any) -> int:
|
||||
"""
|
||||
Получает статус триала для пользователя из таблицы users.
|
||||
|
||||
Args:
|
||||
tg_id (int): Telegram ID пользователя
|
||||
session (Any): Сессия базы данных
|
||||
|
||||
Returns:
|
||||
int: Статус триала (0 - не использован, 1 - использован)
|
||||
"""
|
||||
try:
|
||||
trial = await session.fetchval("SELECT trial FROM users WHERE tg_id = $1", tg_id)
|
||||
@@ -613,15 +448,6 @@ async def get_trial(tg_id: int, session: Any) -> int:
|
||||
async def get_key_count(tg_id: int) -> int:
|
||||
"""
|
||||
Получает количество ключей для указанного пользователя.
|
||||
|
||||
Args:
|
||||
tg_id (int): Telegram ID пользователя
|
||||
|
||||
Returns:
|
||||
int: Количество ключей пользователя, 0 если ключей нет
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при подключении к базе данных
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
@@ -660,13 +486,6 @@ async def add_referral(referred_tg_id: int, referrer_tg_id: int, session: Any):
|
||||
async def handle_referral_on_balance_update(tg_id: int, amount: float):
|
||||
"""
|
||||
Обработка многоуровневой реферальной системы при обновлении баланса пользователя.
|
||||
|
||||
Метод анализирует цепочку рефералов для указанного пользователя и начисляет
|
||||
бонусы реферерам на разных уровнях согласно настроенным процентам.
|
||||
|
||||
Args:
|
||||
tg_id (int): Идентификатор Telegram пользователя, пополнившего баланс
|
||||
amount (float): Сумма пополнения баланса
|
||||
"""
|
||||
|
||||
if amount <= 0:
|
||||
@@ -943,14 +762,6 @@ async def get_referral_stats(referrer_tg_id: int):
|
||||
async def update_key_expiry(client_id: str, new_expiry_time: int, session: Any):
|
||||
"""
|
||||
Обновление времени истечения ключа для указанного клиента.
|
||||
|
||||
Args:
|
||||
client_id (str): Уникальный идентификатор клиента
|
||||
new_expiry_time (int): Новое время истечения ключа
|
||||
session (Any): Сессия подключения к базе данных
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при подключении к базе данных или обновлении ключа
|
||||
"""
|
||||
try:
|
||||
await session.execute(
|
||||
@@ -972,15 +783,6 @@ async def update_key_expiry(client_id: str, new_expiry_time: int, session: Any):
|
||||
async def get_client_id_by_email(email: str):
|
||||
"""
|
||||
Получение идентификатора клиента по электронной почте.
|
||||
|
||||
Args:
|
||||
email (str): Электронная почта клиента
|
||||
|
||||
Returns:
|
||||
str: Идентификатор клиента или None, если клиент не найден
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при подключении к базе данных или выполнении запроса
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
@@ -1010,42 +812,6 @@ async def get_client_id_by_email(email: str):
|
||||
logger.info("Закрытие подключения к базе данных")
|
||||
|
||||
|
||||
async def get_tg_id_by_client_id(client_id: str):
|
||||
"""
|
||||
Получение Telegram ID по идентификатору клиента.
|
||||
|
||||
Args:
|
||||
client_id (str): Идентификатор клиента
|
||||
|
||||
Returns:
|
||||
int или None: Telegram ID клиента, если найден, иначе None
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при подключении к базе данных или выполнении запроса
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
logger.info(f"Установлено подключение к базе данных для поиска Telegram ID по client_id: {client_id}")
|
||||
|
||||
result = await conn.fetchrow("SELECT tg_id FROM keys WHERE client_id = $1", client_id)
|
||||
|
||||
if result:
|
||||
logger.info(f"Найден Telegram ID для client_id: {client_id}")
|
||||
return result["tg_id"]
|
||||
else:
|
||||
logger.warning(f"Не найден Telegram ID для client_id: {client_id}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении Telegram ID для client_id {client_id}: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
logger.info("Закрытие подключения к базе данных")
|
||||
|
||||
|
||||
async def upsert_user(
|
||||
tg_id: int,
|
||||
username: str = None,
|
||||
@@ -1057,27 +823,11 @@ async def upsert_user(
|
||||
) -> dict:
|
||||
"""
|
||||
Обновляет или вставляет информацию о пользователе в базу данных.
|
||||
|
||||
Args:
|
||||
tg_id (int): Идентификатор пользователя в Telegram
|
||||
username (str, optional): Имя пользователя в Telegram
|
||||
first_name (str, optional): Имя пользователя
|
||||
last_name (str, optional): Фамилия пользователя
|
||||
language_code (str, optional): Код языка пользователя
|
||||
is_bot (bool, optional): Флаг, указывающий является ли пользователь ботом
|
||||
session (Any, optional): Существующая сессия базы данных
|
||||
|
||||
Returns:
|
||||
dict: Словарь с информацией о пользователе после обновления/вставки
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при работе с базой данных
|
||||
"""
|
||||
conn = None
|
||||
close_conn = False
|
||||
|
||||
try:
|
||||
# Используем переданную сессию или создаем новое подключение
|
||||
if session:
|
||||
conn = session
|
||||
logger.debug(f"Используем существующую сессию для обновления пользователя {tg_id}")
|
||||
@@ -1086,7 +836,6 @@ async def upsert_user(
|
||||
close_conn = True
|
||||
logger.info(f"Установлено новое подключение к базе данных для обновления пользователя {tg_id}")
|
||||
|
||||
# Выполняем вставку/обновление и сразу получаем обновленные данные
|
||||
user_data = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO users (tg_id, username, first_name, last_name, language_code, is_bot, created_at, updated_at)
|
||||
@@ -1126,14 +875,6 @@ async def upsert_user(
|
||||
async def add_payment(tg_id: int, amount: float, payment_system: str):
|
||||
"""
|
||||
Добавляет информацию о платеже в базу данных.
|
||||
|
||||
Args:
|
||||
tg_id (int): Идентификатор пользователя в Telegram
|
||||
amount (float): Сумма платежа
|
||||
payment_system (str): Система оплаты
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при добавлении платежа
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
@@ -1162,14 +903,6 @@ async def add_payment(tg_id: int, amount: float, payment_system: str):
|
||||
async def add_notification(tg_id: int, notification_type: str, session: Any):
|
||||
"""
|
||||
Добавляет запись о notification в базу данных.
|
||||
|
||||
Args:
|
||||
tg_id (int): Идентификатор пользователя в Telegram
|
||||
notification_type (str): Тип уведомления
|
||||
session (Any): Сессия базы данных для выполнения запроса
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при добавлении notification
|
||||
"""
|
||||
try:
|
||||
await session.execute(
|
||||
@@ -1206,18 +939,6 @@ async def delete_notification(tg_id: int, notification_type: str, session):
|
||||
async def check_notification_time(tg_id: int, notification_type: str, hours: int = 12, session: Any = None) -> bool:
|
||||
"""
|
||||
Проверяет, прошло ли указанное количество часов с момента последнего уведомления.
|
||||
|
||||
Args:
|
||||
tg_id (int): Идентификатор пользователя в Telegram
|
||||
notification_type (str): Тип уведомления
|
||||
hours (int, optional): Количество часов для проверки. По умолчанию 12.
|
||||
session (Any): Сессия базы данных для выполнения запроса
|
||||
|
||||
Returns:
|
||||
bool: True, если с момента последнего уведомления прошло больше указанного времени, иначе False
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при проверке времени уведомления
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
@@ -1259,14 +980,6 @@ async def check_notification_time(tg_id: int, notification_type: str, hours: int
|
||||
async def get_last_notification_time(tg_id: int, notification_type: str, session: Any = None) -> int:
|
||||
"""
|
||||
Возвращает время последнего уведомления в миллисекундах (UTC).
|
||||
|
||||
Args:
|
||||
tg_id (int): Telegram ID пользователя.
|
||||
notification_type (str): Тип уведомления.
|
||||
session (Any): Сессия базы данных.
|
||||
|
||||
Returns:
|
||||
int: Время последнего уведомления в миллисекундах, или None, если уведомления не было.
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
@@ -1334,11 +1047,7 @@ async def get_servers(session: Any = None, include_enabled: bool = False):
|
||||
|
||||
|
||||
async def delete_user_data(session: Any, tg_id: int):
|
||||
try:
|
||||
await session.execute("DELETE FROM gifts WHERE sender_tg_id = $1 OR recipient_tg_id = $1", tg_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"У Вас версия без подарков для {tg_id}: {e}")
|
||||
|
||||
await session.execute("DELETE FROM gifts WHERE sender_tg_id = $1 OR recipient_tg_id = $1", tg_id)
|
||||
await session.execute("DELETE FROM payments WHERE tg_id = $1", tg_id)
|
||||
await session.execute("DELETE FROM users WHERE tg_id = $1", tg_id)
|
||||
await delete_key(tg_id, session)
|
||||
@@ -1355,20 +1064,6 @@ async def store_gift_link(
|
||||
):
|
||||
"""
|
||||
Добавляет информацию о подарке в базу данных.
|
||||
|
||||
Args:
|
||||
gift_id (str): Уникальный идентификатор подарка
|
||||
sender_tg_id (int): Идентификатор пользователя, который отправил подарок
|
||||
selected_months (int): Количество месяцев подписки
|
||||
expiry_time (datetime): Время окончания подписки
|
||||
gift_link (str): Ссылка для активации подарка
|
||||
session (Any): Сессия базы данных для выполнения запроса
|
||||
|
||||
Returns:
|
||||
bool: True, если информация о подарке успешно добавлена, иначе False
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при сохранении информации о подарке
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
@@ -1455,7 +1150,6 @@ async def get_key_details(email, session):
|
||||
"email": record["email"],
|
||||
"is_frozen": record["is_frozen"],
|
||||
"balance": record["balance"],
|
||||
|
||||
"expiry_date": expiry_date.strftime("%d %B %Y года %H:%M"),
|
||||
"days_left_message": days_left_message,
|
||||
"link": public_link or remna_link,
|
||||
@@ -1494,17 +1188,6 @@ async def create_server(
|
||||
):
|
||||
"""
|
||||
Добавляет новый сервер в базу данных.
|
||||
|
||||
Args:
|
||||
cluster_name (str): Название кластера
|
||||
server_name (str): Название сервера
|
||||
api_url (str): URL API сервера
|
||||
subscription_url (str): URL подписки
|
||||
inbound_id (int): ID входящего подключения
|
||||
session (Any): Сессия базы данных
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при добавлении сервера
|
||||
"""
|
||||
try:
|
||||
await session.execute(
|
||||
@@ -1527,13 +1210,6 @@ async def create_server(
|
||||
async def delete_server(server_name: str, session: Any):
|
||||
"""
|
||||
Удаляет сервер из базы данных по его названию.
|
||||
|
||||
Args:
|
||||
server_name (str): Название сервера для удаления
|
||||
session (Any): Сессия базы данных
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при удалении сервера
|
||||
"""
|
||||
try:
|
||||
await session.execute(
|
||||
@@ -1551,14 +1227,6 @@ async def delete_server(server_name: str, session: Any):
|
||||
async def create_coupon_usage(coupon_id: int, user_id: int, session: Any):
|
||||
"""
|
||||
Создаёт запись об использовании купона в базе данных.
|
||||
|
||||
Args:
|
||||
coupon_id (int): ID купона
|
||||
user_id (int): ID пользователя
|
||||
session (Any): Сессия базы данных
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при создании записи
|
||||
"""
|
||||
try:
|
||||
await session.execute(
|
||||
@@ -1579,17 +1247,6 @@ async def create_coupon_usage(coupon_id: int, user_id: int, session: Any):
|
||||
async def check_coupon_usage(coupon_id: int, user_id: int, session: Any) -> bool:
|
||||
"""
|
||||
Проверяет, использовал ли пользователь данный купон.
|
||||
|
||||
Args:
|
||||
coupon_id (int): ID купона для проверки
|
||||
user_id (int): ID пользователя для проверки
|
||||
session (Any): Сессия базы данных
|
||||
|
||||
Returns:
|
||||
bool: True если купон уже использован, False если нет
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при выполнении запроса
|
||||
"""
|
||||
try:
|
||||
result = await session.fetchrow(
|
||||
@@ -1608,13 +1265,6 @@ async def check_coupon_usage(coupon_id: int, user_id: int, session: Any) -> bool
|
||||
async def update_coupon_usage_count(coupon_id: int, session: Any):
|
||||
"""
|
||||
Обновляет счетчик использования купона и его статус.
|
||||
|
||||
Args:
|
||||
coupon_id (int): ID купона для обновления
|
||||
session (Any): Сессия базы данных
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при обновлении данных купона
|
||||
"""
|
||||
try:
|
||||
await session.execute(
|
||||
@@ -1635,16 +1285,6 @@ async def update_coupon_usage_count(coupon_id: int, session: Any):
|
||||
async def get_last_payments(tg_id: int, session: Any):
|
||||
"""
|
||||
Получает последние 3 платежа пользователя.
|
||||
|
||||
Args:
|
||||
tg_id (int): Telegram ID пользователя
|
||||
session (Any): Сессия базы данных
|
||||
|
||||
Returns:
|
||||
list: Список последних платежей пользователя
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при выполнении запроса
|
||||
"""
|
||||
try:
|
||||
records = await session.fetch(
|
||||
@@ -1667,16 +1307,6 @@ async def get_last_payments(tg_id: int, session: Any):
|
||||
async def get_referral_by_referred_id(referred_tg_id: int, session: Any):
|
||||
"""
|
||||
Получает информацию о реферале по ID приглашенного пользователя.
|
||||
|
||||
Args:
|
||||
referred_tg_id (int): ID приглашенного пользователя
|
||||
session (Any): Сессия базы данных
|
||||
|
||||
Returns:
|
||||
dict: Словарь с информацией о реферале или None если не найден
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при выполнении запроса
|
||||
"""
|
||||
try:
|
||||
record = await session.fetchrow(
|
||||
@@ -1702,15 +1332,6 @@ async def get_referral_by_referred_id(referred_tg_id: int, session: Any):
|
||||
async def get_all_keys(session: Any = None):
|
||||
"""
|
||||
Получает все записи из таблицы keys.
|
||||
|
||||
Args:
|
||||
session (Any, optional): Сессия базы данных. По умолчанию None.
|
||||
|
||||
Returns:
|
||||
list: Список всех записей из таблицы keys
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при выполнении запроса
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
@@ -1724,3 +1345,59 @@ async def get_all_keys(session: Any = None):
|
||||
finally:
|
||||
if conn is not None and session is None:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def check_notifications_bulk(notification_type: str, hours: int, session: Any, tg_ids: list[int] = None, emails: list[str] = None) -> list[dict]:
|
||||
"""
|
||||
Проверяет, какие пользователи могут получить уведомление указанного типа, и возвращает их данные.
|
||||
"""
|
||||
try:
|
||||
query = """
|
||||
SELECT
|
||||
u.tg_id,
|
||||
k.email,
|
||||
u.username,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
EXTRACT(EPOCH FROM MAX(n.last_notification_time AT TIME ZONE 'Europe/Moscow' AT TIME ZONE 'UTC')) * 1000 AS last_notification_time
|
||||
FROM users u
|
||||
LEFT JOIN keys k ON u.tg_id = k.tg_id
|
||||
LEFT JOIN notifications n ON u.tg_id = n.tg_id AND n.notification_type = $1
|
||||
WHERE (n.last_notification_time IS NULL OR NOW() - n.last_notification_time > ($2 * INTERVAL '1 hour'))
|
||||
"""
|
||||
params = [notification_type, hours]
|
||||
|
||||
if tg_ids is not None:
|
||||
query += " AND u.tg_id = ANY($3)"
|
||||
params.append(tg_ids)
|
||||
if emails is not None:
|
||||
query += " AND k.email = ANY($" + str(len(params) + 1) + ")"
|
||||
params.append(emails)
|
||||
|
||||
if notification_type == 'inactive_trial':
|
||||
query += """
|
||||
AND u.trial IN (0, -1)
|
||||
AND u.tg_id NOT IN (SELECT tg_id FROM blocked_users)
|
||||
AND u.tg_id NOT IN (SELECT DISTINCT tg_id FROM keys)
|
||||
"""
|
||||
|
||||
query += """
|
||||
GROUP BY u.tg_id, k.email, u.username, u.first_name, u.last_name
|
||||
"""
|
||||
|
||||
users = await session.fetch(query, *params)
|
||||
logger.info(f"Найдено {len(users)} пользователей, готовых к уведомлению типа {notification_type}")
|
||||
return [
|
||||
{
|
||||
"tg_id": user["tg_id"],
|
||||
"email": user["email"],
|
||||
"username": user["username"],
|
||||
"first_name": user["first_name"],
|
||||
"last_name": user["last_name"],
|
||||
"last_notification_time": int(user["last_notification_time"]) if user["last_notification_time"] else None,
|
||||
}
|
||||
for user in users
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при массовой проверке уведомлений типа {notification_type}: {e}")
|
||||
raise
|
||||
@@ -70,9 +70,10 @@ async def handle_servers(callback_query: CallbackQuery):
|
||||
|
||||
text = (
|
||||
"<b>🔧 Управление кластерами</b>\n\n"
|
||||
"<i>📌 Здесь вы можете добавить новый кластер.</i>\n\n"
|
||||
"<i>🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.</i>\n"
|
||||
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер.\n\n"
|
||||
"<blockquote>"
|
||||
"🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.\n"
|
||||
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер."
|
||||
"</blockquote>\n\n"
|
||||
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n"
|
||||
)
|
||||
|
||||
@@ -269,11 +270,20 @@ async def handle_clusters_manage(
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"<b>🔧 Управление кластером {cluster_name}</b>",
|
||||
reply_markup=build_cluster_management_kb(cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("cluster_servers|"), IsAdminFilter())
|
||||
async def handle_cluster_servers(callback: CallbackQuery):
|
||||
cluster_name = callback.data.split("|", 1)[1]
|
||||
servers = await get_servers()
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text=f"<b>📡 Серверы в кластере {cluster_name}</b>",
|
||||
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
|
||||
)
|
||||
|
||||
@@ -302,8 +312,6 @@ async def handle_cluster_availability(
|
||||
|
||||
now = datetime.utcnow()
|
||||
start_time = now - timedelta(minutes=5)
|
||||
start_iso = start_time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
end_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
for server in cluster_servers:
|
||||
server_name = server["server_name"]
|
||||
@@ -537,16 +545,6 @@ async def handle_add_server(callback_query: CallbackQuery, callback_data: AdminS
|
||||
await state.set_state(AdminClusterStates.waiting_for_server_name)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "manage_cluster"), IsAdminFilter())
|
||||
async def handle_manage_cluster_menu(callback_query: CallbackQuery, callback_data: AdminClusterCallback):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"<b>🛠 Управление кластером {cluster_name}</b>\nВыберите действие:",
|
||||
reply_markup=build_cluster_management_kb(cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "add_time"), IsAdminFilter())
|
||||
async def handle_add_time(callback_query: CallbackQuery, callback_data: AdminClusterCallback, state: FSMContext):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
@@ -4,7 +4,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from handlers.buttons import BACK
|
||||
|
||||
from ..panel.keyboard import build_admin_back_btn
|
||||
from ..panel.keyboard import build_admin_back_btn, AdminPanelCallback
|
||||
from ..servers.keyboard import AdminServerCallback
|
||||
|
||||
|
||||
@@ -53,13 +53,6 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🛠 Управление",
|
||||
callback_data=AdminClusterCallback(action="manage_cluster", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(build_admin_back_btn("clusters"))
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -67,6 +60,12 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
|
||||
def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📡 Серверы",
|
||||
callback_data=f"cluster_servers|{cluster_name}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🌐 Доступность",
|
||||
@@ -100,7 +99,7 @@ def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
|
||||
callback_data=AdminPanelCallback(action="clusters").pack()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ from handlers.keys.key_utils import (
|
||||
reset_traffic_in_cluster,
|
||||
update_subscription,
|
||||
)
|
||||
from handlers.utils import generate_random_email, sanitize_key_name
|
||||
from handlers.utils import generate_random_email
|
||||
from logger import logger
|
||||
from utils.csv_export import export_referrals_csv
|
||||
|
||||
@@ -62,10 +62,8 @@ router = Router()
|
||||
|
||||
|
||||
class UserEditorState(StatesGroup):
|
||||
# search
|
||||
waiting_for_user_data = State()
|
||||
waiting_for_key_name = State()
|
||||
# updating data
|
||||
waiting_for_balance = State()
|
||||
waiting_for_expiry_time = State()
|
||||
waiting_for_message_text = State()
|
||||
@@ -116,9 +114,7 @@ async def handle_user_data_input(message: Message, state: FSMContext, session: A
|
||||
if message.text.isdigit():
|
||||
tg_id = int(message.text)
|
||||
else:
|
||||
# Удаление '@' символа в начале сообщения
|
||||
username = message.text.strip().lstrip("@")
|
||||
# Удаление начала ссылки на профиль
|
||||
username = username.replace("https://t.me/", "")
|
||||
|
||||
user = await session.fetchrow("SELECT tg_id FROM users WHERE username = $1", username)
|
||||
@@ -135,24 +131,6 @@ async def handle_user_data_input(message: Message, state: FSMContext, session: A
|
||||
await process_user_search(message, state, session, tg_id)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_key_name, IsAdminFilter())
|
||||
async def handle_key_name_input(message: Message, state: FSMContext, session: Any):
|
||||
kb = build_admin_back_kb()
|
||||
|
||||
if not message.text:
|
||||
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
|
||||
return
|
||||
|
||||
key_name = sanitize_key_name(message.text)
|
||||
key_details = await get_key_details(key_name, session)
|
||||
|
||||
if not key_details:
|
||||
await message.answer(text="🚫 Пользователь с указанным именем ключа не найден.", reply_markup=kb)
|
||||
return
|
||||
|
||||
await process_user_search(message, state, session, key_details["tg_id"])
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminUserEditorCallback.filter(F.action == "users_send_message"),
|
||||
IsAdminFilter(),
|
||||
|
||||
@@ -13,10 +13,9 @@ from config import (
|
||||
REMNAWAVE_PASSWORD,
|
||||
SUPERNODE,
|
||||
TOTAL_GB,
|
||||
ADMIN_ID
|
||||
)
|
||||
from database import delete_notification, get_servers, store_key
|
||||
from handlers.utils import get_least_loaded_cluster
|
||||
from handlers.utils import get_least_loaded_cluster, check_server_key_limit
|
||||
from logger import logger
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
from panels.three_xui import (
|
||||
@@ -786,48 +785,4 @@ async def reset_traffic_in_cluster(cluster_id: str, email: str) -> None:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Reset Traffic] Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def check_server_key_limit(server_info: dict, conn) -> bool:
|
||||
"""
|
||||
Универсальная проверка лимита ключей для сервера в режимах кластеров и стран.
|
||||
"""
|
||||
server_name = server_info.get("server_name")
|
||||
cluster_name = server_info.get("cluster_name")
|
||||
max_keys = server_info.get("max_keys")
|
||||
|
||||
if not max_keys:
|
||||
return True
|
||||
|
||||
identifier = cluster_name if cluster_name else server_name
|
||||
total_keys = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", identifier)
|
||||
|
||||
if total_keys >= max_keys:
|
||||
logger.warning(f"[Key Limit] Сервер {server_name} достиг лимита: {total_keys}/{max_keys}")
|
||||
return False
|
||||
|
||||
usage_percent = total_keys / max_keys
|
||||
|
||||
if usage_percent >= 0.9:
|
||||
notif_key = f"server_warn_{server_name}"
|
||||
already_sent = await conn.fetchval(
|
||||
"SELECT EXISTS (SELECT 1 FROM notifications WHERE tg_id = 0 AND notification_type = $1)",
|
||||
notif_key
|
||||
)
|
||||
if not already_sent:
|
||||
for admin_id in ADMIN_ID:
|
||||
try:
|
||||
await bot.send_message(
|
||||
admin_id,
|
||||
f"⚠️ Сервер <b>{server_name}</b> почти заполнен ({int(usage_percent * 100)}%)."
|
||||
f"\nРекомендуется создать новый для балансировки.",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await conn.execute(
|
||||
"INSERT INTO notifications (tg_id, notification_type) VALUES (0, $1) ON CONFLICT DO NOTHING",
|
||||
notif_key,
|
||||
)
|
||||
|
||||
return True
|
||||
raise
|
||||
@@ -22,6 +22,7 @@ from config import (
|
||||
)
|
||||
from database import (
|
||||
add_notification,
|
||||
check_notifications_bulk,
|
||||
check_notification_time,
|
||||
delete_key,
|
||||
delete_notification,
|
||||
@@ -47,7 +48,7 @@ from handlers.texts import (
|
||||
from handlers.utils import format_hours, format_minutes
|
||||
from logger import logger
|
||||
|
||||
from .notify_utils import send_notification
|
||||
from .notify_utils import send_notification, send_messages_with_limit
|
||||
from .special_notifications import notify_inactive_trial_users, notify_users_no_traffic
|
||||
|
||||
|
||||
@@ -115,6 +116,9 @@ async def periodic_notifications(bot: Bot):
|
||||
|
||||
|
||||
async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, threshold_time_24h: int, keys: list):
|
||||
"""
|
||||
Отправляет уведомления пользователям о том, что их подписка истекает через 24 часа.
|
||||
"""
|
||||
logger.info("Начало проверки подписок, истекающих через 24 часа.")
|
||||
|
||||
expiring_keys = [
|
||||
@@ -122,21 +126,26 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
|
||||
]
|
||||
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 24 часа.")
|
||||
|
||||
tg_ids = [key["tg_id"] for key in expiring_keys]
|
||||
emails = [key.get("email", "") for key in expiring_keys]
|
||||
|
||||
users = await check_notifications_bulk("key_24h", 24, conn, tg_ids=tg_ids, emails=emails)
|
||||
messages = []
|
||||
|
||||
for key in expiring_keys:
|
||||
tg_id = key["tg_id"]
|
||||
email = key.get("email", "")
|
||||
expiry_timestamp = key.get("expiry_time")
|
||||
notification_id = f"{email}_key_24h"
|
||||
|
||||
try:
|
||||
can_notify = await check_notification_time(tg_id, notification_id, hours=24, session=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки уведомления для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
can_notify = await check_notification_time(tg_id, notification_id, hours=24, session=conn)
|
||||
if not can_notify:
|
||||
continue
|
||||
|
||||
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
expiry_timestamp = key.get("expiry_time")
|
||||
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
|
||||
hours_left_formatted = (
|
||||
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
|
||||
@@ -155,12 +164,17 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
|
||||
await process_auto_renew_or_notify(bot, conn, key, notification_id, 1, "notify_24h.jpg", notification_text)
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
try:
|
||||
await send_notification(bot, tg_id, "notify_24h.jpg", notification_text, keyboard)
|
||||
logger.info(f"Отправлено уведомление об истечении подписки через 24 часа для пользователя {tg_id}.")
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить уведомление пользователю {tg_id}: {e}")
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": notification_text,
|
||||
"photo": "notify_24h.jpg",
|
||||
"keyboard": keyboard,
|
||||
})
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
|
||||
if messages:
|
||||
await send_messages_with_limit(bot, messages)
|
||||
logger.info(f"Отправлено {len(messages)} уведомлений об истечении подписки через 24 часа.")
|
||||
|
||||
logger.info("Обработка всех уведомлений за 24 часа завершена.")
|
||||
await asyncio.sleep(1)
|
||||
@@ -177,21 +191,26 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
|
||||
]
|
||||
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 10 часов.")
|
||||
|
||||
tg_ids = [key["tg_id"] for key in expiring_keys]
|
||||
emails = [key.get("email", "") for key in expiring_keys]
|
||||
|
||||
users = await check_notifications_bulk("key_10h", 10, conn, tg_ids=tg_ids, emails=emails)
|
||||
messages = []
|
||||
|
||||
for key in expiring_keys:
|
||||
tg_id = key["tg_id"]
|
||||
email = key.get("email", "")
|
||||
expiry_timestamp = key.get("expiry_time")
|
||||
notification_id = f"{email}_key_10h"
|
||||
|
||||
try:
|
||||
can_notify = await check_notification_time(tg_id, notification_id, hours=10, session=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки уведомления для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
can_notify = await check_notification_time(tg_id, notification_id, hours=10, session=conn)
|
||||
if not can_notify:
|
||||
continue
|
||||
|
||||
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
expiry_timestamp = key.get("expiry_time")
|
||||
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
|
||||
hours_left_formatted = (
|
||||
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
|
||||
@@ -215,12 +234,17 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
|
||||
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
try:
|
||||
await send_notification(bot, tg_id, "notify_10h.jpg", notification_text, keyboard)
|
||||
logger.info(f"Отправлено уведомление об истечении подписки через 10 часов для пользователя {tg_id}.")
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить уведомление пользователю {tg_id}: {e}")
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": notification_text,
|
||||
"photo": "notify_10h.jpg",
|
||||
"keyboard": keyboard,
|
||||
})
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
|
||||
if messages:
|
||||
await send_messages_with_limit(bot, messages)
|
||||
logger.info(f"Отправлено {len(messages)} уведомлений об истечении подписки через 10 часов.")
|
||||
|
||||
logger.info("Обработка всех уведомлений за 10 часов завершена.")
|
||||
await asyncio.sleep(1)
|
||||
@@ -235,6 +259,12 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
expired_keys = [key for key in keys if key.get("expiry_time") and key.get("expiry_time") < current_time]
|
||||
logger.info(f"Найдено {len(expired_keys)} истекших ключей.")
|
||||
|
||||
tg_ids = [key["tg_id"] for key in expired_keys]
|
||||
emails = [key.get("email", "") for key in expired_keys]
|
||||
|
||||
users = await check_notifications_bulk("key_expired", 0, conn, tg_ids=tg_ids, emails=emails)
|
||||
messages = []
|
||||
|
||||
for key in expired_keys:
|
||||
tg_id = key["tg_id"]
|
||||
email = key.get("email", "")
|
||||
@@ -242,11 +272,7 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
server_id = key.get("server_id")
|
||||
notification_id = f"{email}_key_expired"
|
||||
|
||||
try:
|
||||
last_notification_time = await get_last_notification_time(tg_id, notification_id, session=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения времени последнего уведомления для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
last_notification_time = await get_last_notification_time(tg_id, notification_id, session=conn)
|
||||
|
||||
if NOTIFY_RENEW_EXPIRED:
|
||||
try:
|
||||
@@ -285,22 +311,17 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
logger.info(f"🗑 Ключ {client_id} для пользователя {tg_id} успешно удалён.")
|
||||
|
||||
keyboard = build_notification_expired_kb()
|
||||
try:
|
||||
await send_notification(
|
||||
bot,
|
||||
tg_id,
|
||||
"notify_expired.jpg",
|
||||
KEY_DELETED_MSG.format(email=email),
|
||||
keyboard,
|
||||
)
|
||||
logger.info(f"Отправлено уведомление об удалении подписки {email} пользователю {tg_id}.")
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить уведомление об удалении пользователю {tg_id}: {e}")
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": KEY_DELETED_MSG.format(email=email),
|
||||
"photo": "notify_expired.jpg",
|
||||
"keyboard": keyboard,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
if last_notification_time is None:
|
||||
if last_notification_time is None and any(u["tg_id"] == tg_id and u["email"] == email for u in users):
|
||||
keyboard = build_notification_kb(email)
|
||||
|
||||
if NOTIFY_DELETE_DELAY > 0:
|
||||
@@ -327,18 +348,17 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
else:
|
||||
delay_message = KEY_EXPIRED_NO_DELAY_MSG.format(email=email)
|
||||
|
||||
try:
|
||||
await send_notification(
|
||||
bot,
|
||||
tg_id,
|
||||
"notify_expired.jpg",
|
||||
delay_message,
|
||||
keyboard,
|
||||
)
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
logger.info(f"Отправлено уведомление о необходимости продления подписки {email} пользователю {tg_id}.")
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить уведомление о продлении подписки пользователю {tg_id}: {e}")
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": delay_message,
|
||||
"photo": "notify_expired.jpg",
|
||||
"keyboard": keyboard,
|
||||
})
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
|
||||
if messages:
|
||||
await send_messages_with_limit(bot, messages)
|
||||
logger.info(f"Отправлено {len(messages)} уведомлений об истекших ключах.")
|
||||
|
||||
logger.info("Обработка истекших ключей завершена.")
|
||||
await asyncio.sleep(1)
|
||||
@@ -406,13 +426,20 @@ async def process_auto_renew_or_notify(
|
||||
)
|
||||
|
||||
keyboard = build_notification_expired_kb()
|
||||
await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
|
||||
result = await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
|
||||
if result:
|
||||
logger.info(f"✅ Уведомление о продлении подписки {email} отправлено пользователю {tg_id}.")
|
||||
else:
|
||||
logger.warning(f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}.")
|
||||
except KeyError as e:
|
||||
logger.error(f"❌ Ошибка форматирования сообщения KEY_RENEWED: отсутствует ключ {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при продлении ключа {client_id} для пользователя {tg_id}: {e}")
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
|
||||
logger.info(f"📢 Отправлено уведомление об истекающей подписке {email} пользователю {tg_id}.")
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
result = await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
|
||||
if result:
|
||||
logger.info(f"📢 Отправлено уведомление об истекающей подписке {email} пользователю {tg_id}.")
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
else:
|
||||
logger.warning(f"📢 Не удалось отправить уведомление об истекающей подписке {email} пользователю {tg_id}.")
|
||||
@@ -10,6 +10,29 @@ from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def send_messages_with_limit(bot: Bot, messages: list[dict], messages_per_second: int = 25):
|
||||
"""
|
||||
Отправляет сообщения с ограничением по количеству сообщений в секунду.
|
||||
"""
|
||||
batch_size = messages_per_second
|
||||
for i in range(0, len(messages), batch_size):
|
||||
batch = messages[i:i + batch_size]
|
||||
tasks = []
|
||||
for msg in batch:
|
||||
tasks.append(send_notification(
|
||||
bot,
|
||||
msg["tg_id"],
|
||||
msg.get("photo"),
|
||||
msg["text"],
|
||||
msg.get("keyboard")
|
||||
))
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка при отправке сообщений в батче: {e}")
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
|
||||
def rate_limited_send(func):
|
||||
async def wrapper(*args, **kwargs):
|
||||
while True:
|
||||
@@ -27,32 +50,23 @@ def rate_limited_send(func):
|
||||
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,
|
||||
image_filename: str | None,
|
||||
caption: str,
|
||||
keyboard: InlineKeyboardMarkup | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Отправляет уведомление пользователю.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота для отправки сообщений
|
||||
tg_id: Telegram ID пользователя
|
||||
image_filename: Имя файла изображения в директории img
|
||||
caption: Текст сообщения
|
||||
keyboard: Клавиатура для сообщения (опционально)
|
||||
|
||||
Returns:
|
||||
bool: True если сообщение успешно отправлено, False в случае ошибки
|
||||
"""
|
||||
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:
|
||||
@@ -100,4 +114,4 @@ async def _send_text_notification(
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}")
|
||||
return False
|
||||
return False
|
||||
@@ -12,7 +12,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from config import NOTIFY_EXTRA_DAYS, NOTIFY_INACTIVE, NOTIFY_INACTIVE_TRAFFIC, SUPPORT_CHAT_URL, TRIAL_TIME
|
||||
from database import (
|
||||
add_notification,
|
||||
check_notification_time,
|
||||
check_notifications_bulk,
|
||||
create_blocked_user,
|
||||
)
|
||||
from handlers.buttons import MAIN_MENU
|
||||
@@ -20,6 +20,7 @@ from handlers.keys.key_utils import get_user_traffic
|
||||
from handlers.texts import TRIAL_INACTIVE_BONUS_MSG, TRIAL_INACTIVE_FIRST_MSG, ZERO_TRAFFIC_MSG
|
||||
from handlers.utils import format_days
|
||||
from logger import logger
|
||||
from .notify_utils import send_notification, send_messages_with_limit
|
||||
|
||||
|
||||
router = Router()
|
||||
@@ -35,77 +36,54 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
|
||||
"""
|
||||
logger.info("Проверка пользователей, не активировавших пробный период...")
|
||||
|
||||
inactive_trial_users = await conn.fetch(
|
||||
"""
|
||||
SELECT u.tg_id, u.username, u.first_name, u.last_name
|
||||
FROM users u
|
||||
WHERE u.trial IN (0, -1)
|
||||
AND u.tg_id NOT IN (SELECT tg_id FROM blocked_users)
|
||||
AND u.tg_id NOT IN (SELECT DISTINCT tg_id FROM keys)
|
||||
"""
|
||||
)
|
||||
logger.info(f"Найдено {len(inactive_trial_users)} неактивных пользователей.")
|
||||
users = await check_notifications_bulk("inactive_trial", NOTIFY_INACTIVE, conn)
|
||||
logger.info(f"Найдено {len(users)} неактивных пользователей для уведомления.")
|
||||
|
||||
for user in inactive_trial_users:
|
||||
messages = []
|
||||
|
||||
for user in users:
|
||||
tg_id = user["tg_id"]
|
||||
username = user["username"]
|
||||
first_name = user["first_name"]
|
||||
last_name = user["last_name"]
|
||||
display_name = username or first_name or last_name or "Пользователь"
|
||||
|
||||
try:
|
||||
can_notify = await check_notification_time(tg_id, "inactive_trial", hours=NOTIFY_INACTIVE, session=conn)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="🚀 Активировать пробный период",
|
||||
callback_data="create_key",
|
||||
)
|
||||
)
|
||||
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
if can_notify:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="🚀 Активировать пробный период",
|
||||
callback_data="create_key",
|
||||
)
|
||||
)
|
||||
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
keyboard = builder.as_markup()
|
||||
trial_extended = user["last_notification_time"] is not None
|
||||
|
||||
trial_extended = await conn.fetchval(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM notifications
|
||||
WHERE tg_id = $1 AND notification_type = 'inactive_trial'
|
||||
)
|
||||
""",
|
||||
tg_id,
|
||||
)
|
||||
if trial_extended:
|
||||
total_days = NOTIFY_EXTRA_DAYS + TRIAL_TIME
|
||||
message = TRIAL_INACTIVE_BONUS_MSG.format(
|
||||
display_name=display_name,
|
||||
extra_days_formatted=format_days(NOTIFY_EXTRA_DAYS),
|
||||
total_days_formatted=format_days(total_days),
|
||||
)
|
||||
await conn.execute("UPDATE users SET trial = -1 WHERE tg_id = $1", tg_id)
|
||||
else:
|
||||
message = TRIAL_INACTIVE_FIRST_MSG.format(
|
||||
display_name=display_name,
|
||||
trial_time_formatted=format_days(TRIAL_TIME)
|
||||
)
|
||||
|
||||
if trial_extended:
|
||||
total_days = NOTIFY_EXTRA_DAYS + TRIAL_TIME
|
||||
message = TRIAL_INACTIVE_BONUS_MSG.format(
|
||||
display_name=display_name,
|
||||
extra_days_formatted=format_days(NOTIFY_EXTRA_DAYS),
|
||||
total_days_formatted=format_days(total_days),
|
||||
)
|
||||
await conn.execute("UPDATE users SET trial = -1 WHERE tg_id = $1", tg_id)
|
||||
else:
|
||||
message = TRIAL_INACTIVE_FIRST_MSG.format(
|
||||
display_name=display_name,
|
||||
trial_time_formatted=format_days(TRIAL_TIME)
|
||||
)
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": message,
|
||||
"keyboard": keyboard,
|
||||
})
|
||||
await add_notification(tg_id, "inactive_trial", session=conn)
|
||||
|
||||
try:
|
||||
await bot.send_message(tg_id, message, reply_markup=keyboard)
|
||||
logger.info(f"📩 Отправлено уведомление неактивному пользователю {tg_id}.")
|
||||
await add_notification(tg_id, "inactive_trial", session=conn)
|
||||
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}. Добавляем в blocked_users.")
|
||||
await create_blocked_user(tg_id, conn)
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка при отправке уведомления пользователю {tg_id}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка при обработке пользователя {tg_id}: {e}")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
if messages:
|
||||
await send_messages_with_limit(bot, messages)
|
||||
logger.info(f"Отправлено {len(messages)} уведомлений неактивным пользователям.")
|
||||
|
||||
logger.info("✅ Проверка пользователей с неактивным пробным периодом завершена.")
|
||||
|
||||
@@ -175,11 +153,14 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti
|
||||
message = ZERO_TRAFFIC_MSG.format(email=email)
|
||||
|
||||
try:
|
||||
await bot.send_message(tg_id, message, reply_markup=keyboard)
|
||||
logger.info(f"📩 Отправлено уведомление пользователю {tg_id} о нулевом трафике.")
|
||||
result = await send_notification(bot, tg_id, None, message, keyboard)
|
||||
await conn.execute(
|
||||
"UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id
|
||||
)
|
||||
if result:
|
||||
logger.info(f"📩 Отправлено уведомление пользователю {tg_id} о нулевом трафике.")
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_id} о нулевом трафике.")
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
|
||||
await create_blocked_user(tg_id, conn)
|
||||
@@ -194,4 +175,4 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления notified для пользователя {tg_id}: {e}")
|
||||
|
||||
logger.info("✅ Обработка пользователей с нулевым трафиком завершена.")
|
||||
logger.info("✅ Обработка пользователей с нулевым трафиком завершена.")
|
||||
+95
-71
@@ -11,96 +11,104 @@ import asyncpg
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup, InputMediaPhoto, Message
|
||||
|
||||
from bot import bot
|
||||
from config import DATABASE_URL
|
||||
from config import DATABASE_URL, ADMIN_ID
|
||||
from database import get_all_keys, get_servers
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def get_usd_rate():
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get("https://www.cbr-xml-daily.ru/daily_json.js") as response:
|
||||
if response.status == 200:
|
||||
data = await response.text()
|
||||
usd = float(json.loads(data)["Valute"]["USD"]["Value"])
|
||||
else:
|
||||
usd = float(100)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error fetching USD rate: {e}")
|
||||
usd = float(100)
|
||||
return usd
|
||||
|
||||
|
||||
def sanitize_key_name(key_name: str) -> str:
|
||||
"""
|
||||
Очищает название ключа, оставляя только допустимые символы.
|
||||
|
||||
Args:
|
||||
key_name (str): Исходное название ключа.
|
||||
|
||||
Returns:
|
||||
str: Очищенное название ключа в нижнем регистре.
|
||||
"""
|
||||
return re.sub(r"[^a-z0-9@._-]", "", key_name.lower())
|
||||
|
||||
|
||||
def generate_random_email(length: int = 6) -> str:
|
||||
def generate_random_email(length: int = 8) -> str:
|
||||
"""
|
||||
Генерирует случайный email с заданной длиной.
|
||||
|
||||
Args:
|
||||
length (int, optional): Длина случайной строки. По умолчанию 6.
|
||||
|
||||
Returns:
|
||||
str: Сгенерированная случайная строка.
|
||||
"""
|
||||
return "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(length)) if length > 0 else ""
|
||||
|
||||
|
||||
async def get_least_loaded_cluster() -> str:
|
||||
"""
|
||||
Определяет кластер с наименьшей загрузкой.
|
||||
|
||||
Returns:
|
||||
str: Идентификатор наименее загруженного кластера.
|
||||
Возвращает кластер с наименьшей загрузкой, где есть хотя бы один сервер с доступным лимитом.
|
||||
"""
|
||||
servers = await get_servers()
|
||||
server_to_cluster = {}
|
||||
cluster_loads = dict.fromkeys(servers.keys(), 0)
|
||||
cluster_loads = {}
|
||||
|
||||
for cluster_name, cluster_servers in servers.items():
|
||||
cluster_loads[cluster_name] = 0
|
||||
for server in cluster_servers:
|
||||
server_to_cluster[server["server_name"]] = cluster_name
|
||||
logger.info(f"Сопоставление серверов и кластеров: {server_to_cluster}")
|
||||
|
||||
async with asyncpg.create_pool(DATABASE_URL) as pool:
|
||||
async with pool.acquire() as conn:
|
||||
keys = await get_all_keys(conn)
|
||||
for key in keys:
|
||||
server_id = key["server_id"]
|
||||
|
||||
cluster_id = server_to_cluster.get(server_id, server_id)
|
||||
|
||||
if cluster_id in cluster_loads:
|
||||
cluster_loads[cluster_id] += 1
|
||||
else:
|
||||
logger.warning(f"⚠️ Сервер {server_id} не найден в известных кластерах!")
|
||||
logger.info(f"Загруженность кластеров после запроса к БД: {cluster_loads}")
|
||||
if not cluster_loads:
|
||||
logger.warning("⚠️ В базе данных или конфигурации нет кластеров!")
|
||||
|
||||
available_clusters = {}
|
||||
for cluster_name, cluster_servers in servers.items():
|
||||
for server in cluster_servers:
|
||||
if server.get("enabled", True) and await check_server_key_limit(server, conn):
|
||||
available_clusters[cluster_name] = cluster_loads[cluster_name]
|
||||
break
|
||||
|
||||
if not available_clusters:
|
||||
logger.warning("❌ Нет доступных кластеров с лимитом ключей!")
|
||||
return "cluster1"
|
||||
least_loaded_cluster = min(cluster_loads, key=lambda k: (cluster_loads[k], k))
|
||||
logger.info(f"✅ Выбран наименее загруженный кластер: {least_loaded_cluster}")
|
||||
|
||||
least_loaded_cluster = min(available_clusters, key=lambda k: (available_clusters[k], k))
|
||||
logger.info(f"✅ Выбран наименее загруженный кластер с лимитом: {least_loaded_cluster}")
|
||||
|
||||
return least_loaded_cluster
|
||||
|
||||
|
||||
async def check_server_key_limit(server_info: dict, conn) -> bool:
|
||||
"""
|
||||
Универсальная проверка лимита ключей для сервера в режимах кластеров и стран.
|
||||
"""
|
||||
server_name = server_info.get("server_name")
|
||||
cluster_name = server_info.get("cluster_name")
|
||||
max_keys = server_info.get("max_keys")
|
||||
|
||||
if not max_keys:
|
||||
return True
|
||||
|
||||
identifier = cluster_name if cluster_name else server_name
|
||||
total_keys = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", identifier)
|
||||
|
||||
if total_keys >= max_keys:
|
||||
logger.warning(f"[Key Limit] Сервер {server_name} достиг лимита: {total_keys}/{max_keys}")
|
||||
return False
|
||||
|
||||
usage_percent = total_keys / max_keys
|
||||
|
||||
if usage_percent >= 0.9:
|
||||
notif_key = f"server_warn_{server_name}"
|
||||
already_sent = await conn.fetchval(
|
||||
"SELECT EXISTS (SELECT 1 FROM notifications WHERE tg_id = 0 AND notification_type = $1)",
|
||||
notif_key
|
||||
)
|
||||
if not already_sent:
|
||||
for admin_id in ADMIN_ID:
|
||||
try:
|
||||
await bot.send_message(
|
||||
admin_id,
|
||||
f"⚠️ Сервер <b>{server_name}</b> почти заполнен ({int(usage_percent * 100)}%)."
|
||||
f"\nРекомендуется создать новый для балансировки.",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await conn.execute(
|
||||
"INSERT INTO notifications (tg_id, notification_type) VALUES (0, $1) ON CONFLICT DO NOTHING",
|
||||
notif_key,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def handle_error(tg_id: int, callback_query: object | None = None, message: str = "") -> None:
|
||||
"""
|
||||
Обрабатывает ошибку, отправляя сообщение пользователю.
|
||||
|
||||
Args:
|
||||
tg_id (int): Идентификатор пользователя в Telegram.
|
||||
callback_query (Optional[object], optional): Объект запроса обратного вызова. По умолчанию None.
|
||||
message (str, optional): Текст сообщения об ошибке. По умолчанию пустая строка.
|
||||
"""
|
||||
try:
|
||||
if callback_query and hasattr(callback_query, "message"):
|
||||
@@ -129,7 +137,9 @@ def format_months(months: int) -> str:
|
||||
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, 'день', 'дня', 'дней')}"
|
||||
@@ -157,15 +167,6 @@ async def edit_or_send_message(
|
||||
):
|
||||
"""
|
||||
Универсальная функция для редактирования исходного сообщения target_message.
|
||||
|
||||
- Если media_path указан и существует, считается, что сообщение содержит фото, и используется редактирование медиа
|
||||
(замена фото и подписи) через edit_media. Если редактирование не удаётся, отправляется новое сообщение с фото.
|
||||
|
||||
- Если media_path не указан:
|
||||
- Если force_text=False и target_message уже имеет caption, пытаемся отредактировать подпись (edit_caption).
|
||||
- Иначе (или если редактирование caption не удалось) — редактируем текст (edit_text).
|
||||
|
||||
В случае неудачи fallback – отправка нового сообщения.
|
||||
"""
|
||||
if media_path and os.path.isfile(media_path):
|
||||
async with aiofiles.open(media_path, "rb") as f:
|
||||
@@ -210,11 +211,6 @@ async def edit_or_send_message(
|
||||
def convert_to_bytes(value: float, unit: str) -> int:
|
||||
"""
|
||||
Конвертирует значение с указанной единицей измерения в байты.
|
||||
Args:
|
||||
value (float): Числовое значение.
|
||||
unit (str): Единица измерения ('KB', 'MB', 'GB', 'TB').
|
||||
Returns:
|
||||
int: Количество байт.
|
||||
"""
|
||||
KB = 1024
|
||||
MB = KB * 1024
|
||||
@@ -244,3 +240,31 @@ async def is_full_remnawave_cluster(cluster_id: str, session) -> bool:
|
||||
cluster_id,
|
||||
)
|
||||
return server and server["panel_type"].lower() == "remnawave"
|
||||
|
||||
|
||||
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_hours(hours: int) -> str:
|
||||
"""Форматирует количество часов с правильным склонением"""
|
||||
if hours <= 0:
|
||||
return "0 часов"
|
||||
return f"{hours} {get_plural_form(hours, 'час', 'часа', 'часов')}"
|
||||
|
||||
|
||||
def format_minutes(minutes: int) -> str:
|
||||
"""Форматирует количество минут с правильным склонением"""
|
||||
if minutes <= 0:
|
||||
return "0 минут"
|
||||
return f"{minutes} {get_plural_form(minutes, 'минута', 'минуты', 'минут')}"
|
||||
Reference in New Issue
Block a user