diff --git a/handlers/admin/management/management_handler.py b/handlers/admin/management/management_handler.py
index 0e8a5636..de44f32a 100644
--- a/handlers/admin/management/management_handler.py
+++ b/handlers/admin/management/management_handler.py
@@ -29,7 +29,7 @@ async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
"""Запрашивает у администратора новый домен."""
await state.set_state(AdminManagementStates.waiting_for_new_domain)
await callback_query.message.edit_text(
- text="🌐 Введите новый домен (без https://):\nПример: pocomachodomen.ru",
+ text="🌐 Введите новый домен (без https://):\nПример: solobotdomen.ru",
)
diff --git a/handlers/admin/users/keyboard.py b/handlers/admin/users/keyboard.py
index a01ba28e..bfee3fd8 100644
--- a/handlers/admin/users/keyboard.py
+++ b/handlers/admin/users/keyboard.py
@@ -4,7 +4,7 @@ from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
-from config import RENEWAL_PRICES
+from config import RENEWAL_PRICES, TOTAL_GB
from ..panel.keyboard import build_admin_back_btn
@@ -27,6 +27,11 @@ def build_user_edit_kb(tg_id: int, key_records: list) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
current_time = datetime.now(tz=timezone.utc)
+ builder.button(
+ text="➕ Создать ключ",
+ callback_data=AdminUserEditorCallback(action="users_create_key", tg_id=tg_id).pack(),
+ )
+
for record in key_records:
email = record["email"]
expiry = datetime.fromtimestamp(record["expiry_time"] / 1000, tz=timezone.utc)
@@ -174,6 +179,13 @@ def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
text="📊 Трафик",
callback_data=AdminUserEditorCallback(action="users_traffic", data=email, tg_id=key_details["tg_id"]).pack(),
)
+ if TOTAL_GB > 0:
+ builder.button(
+ text="♻️ Сбросить трафик",
+ callback_data=AdminUserEditorCallback(
+ action="users_reset_traffic", data=email, tg_id=key_details["tg_id"]
+ ).pack(),
+ )
builder.row(build_editor_back_btn(key_details["tg_id"], True))
builder.adjust(1)
return builder.as_markup()
diff --git a/handlers/admin/users/users_handler.py b/handlers/admin/users/users_handler.py
index e06a5a59..dcb8df6b 100644
--- a/handlers/admin/users/users_handler.py
+++ b/handlers/admin/users/users_handler.py
@@ -1,4 +1,5 @@
import asyncio
+import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
@@ -11,7 +12,7 @@ from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
-from config import TOTAL_GB
+from config import TOTAL_GB, PUBLIC_LINK, RENEWAL_PRICES
from database import (
delete_key,
@@ -22,9 +23,10 @@ from database import (
update_balance,
update_key_expiry,
update_trial,
+ store_key
)
from filters.admin import IsAdminFilter
-from handlers.keys.key_utils import delete_key_from_cluster, get_user_traffic, renew_key_in_cluster, update_subscription
+from handlers.keys.key_utils import delete_key_from_cluster, get_user_traffic, renew_key_in_cluster, update_subscription, create_key_on_cluster, reset_traffic_in_cluster
from handlers.utils import sanitize_key_name
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn, build_admin_back_kb
from .keyboard import (
@@ -43,6 +45,7 @@ from .keyboard import (
)
from logger import logger
from utils.csv_export import export_referrals_csv
+from handlers.utils import generate_random_email
MOSCOW_TZ = pytz.timezone("Europe/Moscow")
@@ -784,3 +787,125 @@ async def handle_users_export_referrals(
await callback_query.message.answer_document(
document=csv_file, caption=f"Список рефералов для пользователя {referrer_tg_id}."
)
+
+
+@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_create_key"), IsAdminFilter())
+async def handle_create_key_select_cluster(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext, session: Any):
+ tg_id = callback_data.tg_id
+ await state.update_data(tg_id=tg_id)
+
+ servers = await get_servers(session)
+ cluster_names = list(servers.keys())
+
+ if not cluster_names:
+ await callback_query.message.edit_text("❌ Нет доступных кластеров для создания ключа.", reply_markup=build_editor_kb(tg_id))
+ return
+
+ builder = InlineKeyboardBuilder()
+ for cluster in cluster_names:
+ builder.button(
+ text=f"🌐 {cluster}",
+ callback_data=AdminUserEditorCallback(action="users_create_key_cluster", tg_id=tg_id, data=cluster).pack(),
+ )
+ builder.row(build_admin_back_btn())
+
+ await callback_query.message.edit_text(
+ "🌐 Выберите кластер для создания ключа:",
+ reply_markup=builder.as_markup()
+ )
+
+
+@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_create_key_cluster"), IsAdminFilter())
+async def handle_create_key_cluster(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext):
+ tg_id = callback_data.tg_id
+ cluster_name = callback_data.data
+
+ await state.update_data(tg_id=tg_id, cluster_name=cluster_name)
+
+ builder = InlineKeyboardBuilder()
+ for months, _ in RENEWAL_PRICES.items():
+ builder.button(
+ text=f"{months} мес.",
+ callback_data=f"create_key_duration|{tg_id}|{cluster_name}|{months}"
+ )
+ builder.adjust(1)
+ builder.row(build_admin_back_btn())
+
+ await callback_query.message.edit_text(
+ text=f"🕒 Выберите срок действия ключа для кластера {cluster_name}:",
+ reply_markup=builder.as_markup()
+ )
+
+
+@router.callback_query(F.data.startswith("create_key_duration|"), IsAdminFilter())
+async def handle_create_key_duration(callback_query: CallbackQuery, session: Any):
+ try:
+ parts = callback_query.data.split("|")
+ tg_id = int(parts[1])
+ cluster_name = parts[2]
+ months = int(parts[3])
+
+ client_id = str(uuid.uuid4())
+ email = generate_random_email()
+ expiry = datetime.now(tz=timezone.utc) + timedelta(days=30 * months)
+ expiry_ms = int(expiry.timestamp() * 1000)
+
+ await create_key_on_cluster(cluster_name, tg_id, client_id, email, expiry_ms)
+
+ public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
+
+ await store_key(
+ tg_id=tg_id,
+ client_id=client_id,
+ email=email,
+ expiry_time=expiry_ms,
+ key=public_link,
+ server_id=cluster_name,
+ session=session,
+ )
+
+ await callback_query.message.edit_text(
+ f"✅ Ключ успешно создан в кластере {cluster_name} на {months} мес.!",
+ reply_markup=build_editor_kb(tg_id)
+ )
+
+ except Exception as e:
+ logger.error(f"Ошибка при создании ключа: {e}")
+ await callback_query.message.edit_text(
+ "❌ Не удалось создать ключ. Попробуйте позже.",
+ reply_markup=build_editor_kb(tg_id)
+ )
+
+
+@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_reset_traffic"), IsAdminFilter())
+async def handle_reset_traffic(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any):
+ tg_id = callback_data.tg_id
+ email = callback_data.data
+
+ record = await session.fetchrow(
+ "SELECT server_id, client_id FROM keys WHERE tg_id = $1 AND email = $2",
+ tg_id,
+ email,
+ )
+
+ if not record:
+ await callback_query.message.edit_text(
+ "❌ Ключ не найден в базе данных.",
+ reply_markup=build_editor_kb(tg_id)
+ )
+ return
+
+ cluster_id = record["server_id"]
+
+ try:
+ await reset_traffic_in_cluster(cluster_id, email)
+ await callback_query.message.edit_text(
+ f"✅ Трафик для ключа {email} успешно сброшен.",
+ reply_markup=build_editor_kb(tg_id)
+ )
+ except Exception as e:
+ logger.error(f"Ошибка при сбросе трафика: {e}")
+ await callback_query.message.edit_text(
+ "❌ Произошла ошибка при сбросе трафика. Попробуйте позже.",
+ reply_markup=build_editor_kb(tg_id)
+ )
\ No newline at end of file
diff --git a/handlers/keys/key_utils.py b/handlers/keys/key_utils.py
index d501bf8c..b86d95ab 100644
--- a/handlers/keys/key_utils.py
+++ b/handlers/keys/key_utils.py
@@ -480,3 +480,51 @@ async def toggle_client_on_cluster(cluster_id: str, email: str, client_id: str,
except Exception as e:
logger.error(f"Ошибка при изменении состояния клиента {email} в кластере {cluster_id}: {e}")
return {"status": "error", "error": str(e)}
+
+
+async def reset_traffic_in_cluster(cluster_id: str, email: str) -> None:
+ """
+ Сбрасывает трафик клиента на всех серверах указанного кластера (или конкретного сервера).
+
+ Args:
+ cluster_id (str): ID кластера или имя сервера
+ email (str): Email клиента (будет преобразован в уникальный для SUPERNODE)
+ """
+ try:
+ servers = await get_servers()
+ cluster = servers.get(cluster_id)
+
+ if not cluster:
+ found_servers = []
+ for _, server_list in servers.items():
+ for server_info in server_list:
+ if server_info.get("server_name", "").lower() == cluster_id.lower():
+ found_servers.append(server_info)
+
+ if found_servers:
+ cluster = found_servers
+ else:
+ raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
+
+ tasks = []
+ for server_info in cluster:
+ api_url = server_info["api_url"]
+ inbound_id = server_info.get("inbound_id")
+ server_name = server_info.get("server_name", "unknown")
+
+ if not inbound_id:
+ logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
+ continue
+
+ xui = AsyncApi(api_url, username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=logger)
+
+ unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
+
+ tasks.append(xui.client.reset_stats(int(inbound_id), unique_email))
+
+ await asyncio.gather(*tasks, return_exceptions=True)
+ logger.info(f"✅ Трафик клиента {email} успешно сброшен на всех серверах кластера {cluster_id}")
+
+ except Exception as e:
+ logger.error(f"❌ Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
+ raise
diff --git a/handlers/notifications/general_notifications.py b/handlers/notifications/general_notifications.py
index bd51acf6..c882398b 100644
--- a/handlers/notifications/general_notifications.py
+++ b/handlers/notifications/general_notifications.py
@@ -258,6 +258,20 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
delete_immediately = NOTIFY_DELETE_DELAY == 0
delete_after_delay = False
+ if NOTIFY_DELETE_DELAY > 0 and last_notification_time is not None:
+ minutes_since = (current_time - last_notification_time) / (1000 * 60)
+ if minutes_since >= NOTIFY_DELETE_DELAY / 2 and minutes_since < NOTIFY_DELETE_DELAY:
+ try:
+ await conn.execute(
+ "DELETE FROM notifications WHERE tg_id = $1 AND notification_type = $2",
+ tg_id,
+ notification_id,
+ )
+ logger.info(f"⛔ Уведомление {notification_id} для {tg_id} удалено (прошло больше половины задержки).")
+ except Exception as e:
+ logger.error(f"Ошибка при удалении уведомления: {e}")
+ continue
+
if last_notification_time is not None:
delete_after_delay = (current_time - last_notification_time) / (1000 * 60) >= NOTIFY_DELETE_DELAY
logger.info(
diff --git a/handlers/profile.py b/handlers/profile.py
index 17c8f8bc..c9268df9 100644
--- a/handlers/profile.py
+++ b/handlers/profile.py
@@ -25,6 +25,8 @@ from config import (
RENEWAL_PLANS,
TRIAL_TIME,
USERNAME_BOT,
+ REFERRAL_BUTTON,
+ GIFT_BUTTON
)
from database import get_balance, get_key_count, get_last_payments, get_referral_stats, get_trial
from handlers.buttons.profile import (
@@ -87,10 +89,15 @@ async def process_callback_view_profile(
else:
builder.row(InlineKeyboardButton(text=MY_SUBS, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=BALANCE, callback_data="balance"))
- builder.row(
- InlineKeyboardButton(text=INVITE, callback_data="invite"),
- InlineKeyboardButton(text=GIFTS, callback_data="gifts"),
- )
+
+ row_buttons = []
+ if REFERRAL_BUTTON:
+ row_buttons.append(InlineKeyboardButton(text=INVITE, callback_data="invite"))
+ if GIFT_BUTTON:
+ row_buttons.append(InlineKeyboardButton(text=GIFTS, callback_data="gifts"))
+ if row_buttons:
+ builder.row(*row_buttons)
+
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions"))
if admin: