refactoring/notify_optimization/least_loaded_cluster for key_limit/rm args

This commit is contained in:
Vladless
2025-04-21 00:17:16 +03:00
parent ef29a8e822
commit cc3ce3a8bb
10 changed files with 340 additions and 687 deletions
+57 -380
View File
@@ -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
+16 -18
View File
@@ -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
+8 -9
View File
@@ -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()
)
)
+1 -23
View File
@@ -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(),
+2 -47
View File
@@ -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
+87 -60
View File
@@ -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}.")
+28 -14
View File
@@ -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
+45 -64
View File
@@ -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
View File
@@ -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, 'минута', 'минуты', 'минут')}"
+1 -1
View File
File diff suppressed because one or more lines are too long