545 lines
22 KiB
Python
545 lines
22 KiB
Python
import asyncio
|
|
import uuid
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
import pytz
|
|
|
|
from aiogram import F, Router
|
|
from aiogram.fsm.context import FSMContext
|
|
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|
from config import (
|
|
ADMIN_PASSWORD,
|
|
ADMIN_USERNAME,
|
|
CONNECT_ANDROID,
|
|
CONNECT_IOS,
|
|
CONNECT_PHONE_BUTTON,
|
|
DOWNLOAD_ANDROID,
|
|
DOWNLOAD_IOS,
|
|
NOTIFY_EXTRA_DAYS,
|
|
PUBLIC_LINK,
|
|
RENEWAL_PRICES,
|
|
SUPPORT_CHAT_URL,
|
|
TRIAL_TIME,
|
|
TRIAL_TIME_DISABLE,
|
|
USE_COUNTRY_SELECTION,
|
|
USE_NEW_PAYMENT_FLOW,
|
|
)
|
|
from py3xui import AsyncApi
|
|
|
|
from bot import bot
|
|
from client import delete_client
|
|
from database import (
|
|
add_connection,
|
|
check_connection_exists,
|
|
create_temporary_data,
|
|
get_balance,
|
|
get_key_details,
|
|
get_trial,
|
|
store_key,
|
|
update_balance,
|
|
update_trial,
|
|
)
|
|
from handlers.buttons.add_subscribe import (
|
|
DOWNLOAD_ANDROID_BUTTON,
|
|
DOWNLOAD_IOS_BUTTON,
|
|
IMPORT_ANDROID,
|
|
IMPORT_IOS,
|
|
PC_BUTTON,
|
|
TV_BUTTON,
|
|
)
|
|
from handlers.keys.key_utils import create_client_on_server, create_key_on_cluster
|
|
from handlers.payments.robokassa_pay import handle_custom_amount_input
|
|
from handlers.payments.yookassa_pay import process_custom_amount_input
|
|
from handlers.texts import DISCOUNTS, key_message_success
|
|
from handlers.utils import edit_or_send_message, generate_random_email, get_least_loaded_cluster
|
|
from logger import logger
|
|
|
|
|
|
router = Router()
|
|
|
|
moscow_tz = pytz.timezone("Europe/Moscow")
|
|
|
|
|
|
class Form(FSMContext):
|
|
waiting_for_server_selection = "waiting_for_server_selection"
|
|
waiting_for_key_name = "waiting_for_key_name"
|
|
viewing_profile = "viewing_profile"
|
|
waiting_for_message = "waiting_for_message"
|
|
|
|
|
|
@router.callback_query(F.data == "create_key")
|
|
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
|
tg_id = callback_query.message.chat.id
|
|
await handle_key_creation(tg_id, state, session, callback_query)
|
|
|
|
|
|
async def handle_key_creation(
|
|
tg_id: int,
|
|
state: FSMContext,
|
|
session: Any,
|
|
message_or_query: Message | CallbackQuery,
|
|
):
|
|
"""Создание ключа с учётом выбора тарифного плана."""
|
|
current_time = datetime.now(moscow_tz)
|
|
|
|
if not TRIAL_TIME_DISABLE:
|
|
trial_status = await get_trial(tg_id, session)
|
|
if trial_status in [0, -1]:
|
|
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
|
|
expiry_time = current_time + timedelta(days=TRIAL_TIME + extra_days)
|
|
logger.info(f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}.")
|
|
updated = await update_trial(tg_id, 1, session)
|
|
if updated:
|
|
await edit_or_send_message(
|
|
target_message=message_or_query
|
|
if isinstance(message_or_query, Message)
|
|
else message_or_query.message,
|
|
text="⏳ Пожалуйста, подождите, создаем вам подключение...",
|
|
reply_markup=None,
|
|
)
|
|
|
|
await create_key(tg_id, expiry_time, state, session, message_or_query)
|
|
return
|
|
else:
|
|
logger.error(f"Не удалось обновить статус триального периода для пользователя {tg_id}.")
|
|
|
|
builder = InlineKeyboardBuilder()
|
|
for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
|
|
discount_text = ""
|
|
if plan_id in DISCOUNTS:
|
|
discount_percentage = DISCOUNTS[plan_id]
|
|
discount_text = f" ({discount_percentage}% скидка)"
|
|
if index == len(RENEWAL_PRICES) - 1:
|
|
discount_text = f" ({discount_percentage}% 🔥)"
|
|
builder.row(
|
|
InlineKeyboardButton(
|
|
text=f"📅 {plan_id} мес. - {price}₽{discount_text}",
|
|
callback_data=f"select_plan_{plan_id}",
|
|
)
|
|
)
|
|
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
|
|
|
if isinstance(message_or_query, CallbackQuery):
|
|
target_message = message_or_query.message
|
|
else:
|
|
target_message = message_or_query
|
|
|
|
await edit_or_send_message(
|
|
target_message=target_message,
|
|
text="💳 Выберите тарифный план для создания нового ключа:",
|
|
reply_markup=builder.as_markup(),
|
|
media_path=None,
|
|
)
|
|
|
|
await state.update_data(tg_id=tg_id)
|
|
await state.set_state(Form.waiting_for_server_selection)
|
|
|
|
|
|
@router.callback_query(F.data.startswith("select_plan_"))
|
|
async def select_tariff_plan(callback_query: CallbackQuery, session: Any):
|
|
tg_id = callback_query.message.chat.id
|
|
plan_id = callback_query.data.split("_")[-1]
|
|
plan_price = RENEWAL_PRICES.get(plan_id)
|
|
if plan_price is None:
|
|
await callback_query.message.answer("🚫 Неверный тарифный план.")
|
|
return
|
|
duration_days = int(plan_id) * 30
|
|
balance = await get_balance(tg_id)
|
|
if balance < plan_price:
|
|
required_amount = plan_price - balance
|
|
await create_temporary_data(
|
|
session,
|
|
tg_id,
|
|
"waiting_for_payment",
|
|
{
|
|
"plan_id": plan_id,
|
|
"plan_price": plan_price,
|
|
"duration_days": duration_days,
|
|
"required_amount": required_amount,
|
|
},
|
|
)
|
|
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
|
|
await process_custom_amount_input(callback_query, session)
|
|
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
|
|
await handle_custom_amount_input(callback_query, session)
|
|
else:
|
|
builder = InlineKeyboardBuilder()
|
|
builder.row(InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="pay"))
|
|
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
|
await edit_or_send_message(
|
|
target_message=callback_query.message,
|
|
text=f"💳 Недостаточно средств. Для продолжения необходимо пополнить баланс на {required_amount}₽.",
|
|
reply_markup=builder.as_markup(),
|
|
media_path=None,
|
|
)
|
|
return
|
|
builder = InlineKeyboardBuilder()
|
|
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
|
|
|
|
await edit_or_send_message(
|
|
target_message=callback_query.message,
|
|
text="⏳ Подождите, создаем вам подключение...",
|
|
reply_markup=builder.as_markup(),
|
|
)
|
|
|
|
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
|
|
await create_key(tg_id, expiry_time, None, session, callback_query, None, plan_id)
|
|
await update_balance(tg_id, -plan_price, session)
|
|
|
|
|
|
async def create_key(
|
|
tg_id: int,
|
|
expiry_time: datetime,
|
|
state: FSMContext | None,
|
|
session: Any,
|
|
message_or_query: Message | CallbackQuery | None = None,
|
|
old_key_name: str = None,
|
|
plan: int = None,
|
|
):
|
|
"""Создаёт ключ с заданным сроком действия."""
|
|
|
|
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
|
if not await check_connection_exists(tg_id):
|
|
await add_connection(tg_id, balance=0.0, trial=0, session=session)
|
|
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
|
|
|
|
if USE_COUNTRY_SELECTION:
|
|
logger.info("[Country Selection] USE_COUNTRY_SELECTION включен. Получение наименее загруженного кластера")
|
|
least_loaded_cluster = await get_least_loaded_cluster()
|
|
logger.info(
|
|
f"[Country Selection] Наименее загруженный кластер: {least_loaded_cluster}. Получаем список серверов"
|
|
)
|
|
servers = await session.fetch(
|
|
"SELECT server_name FROM servers WHERE cluster_name = $1",
|
|
least_loaded_cluster,
|
|
)
|
|
countries = [server["server_name"] for server in servers]
|
|
logger.info(f"[Country Selection] Список серверов: {countries}")
|
|
|
|
builder = InlineKeyboardBuilder()
|
|
ts = int(expiry_time.timestamp())
|
|
for country in countries:
|
|
if old_key_name:
|
|
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
|
|
else:
|
|
callback_data = f"select_country|{country}|{ts}"
|
|
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
|
|
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="profile"))
|
|
|
|
if target_message:
|
|
await edit_or_send_message(
|
|
target_message=target_message,
|
|
text="🌍 Пожалуйста, выберите страну для вашего ключа:",
|
|
reply_markup=builder.as_markup(),
|
|
media_path=None,
|
|
)
|
|
else:
|
|
await bot.send_message(
|
|
chat_id=tg_id,
|
|
text="🌍 Пожалуйста, выберите страну для вашего ключа:",
|
|
reply_markup=builder.as_markup(),
|
|
)
|
|
return
|
|
|
|
while True:
|
|
key_name = generate_random_email()
|
|
logger.info(f"[Key Generation] Сгенерировано имя ключа: {key_name} для пользователя {tg_id}")
|
|
existing_key = await get_key_details(key_name, session)
|
|
if not existing_key:
|
|
break
|
|
logger.warning(f"[Key Generation] Имя ключа {key_name} уже существует. Генерация нового.")
|
|
|
|
client_id = str(uuid.uuid4())
|
|
email = key_name.lower()
|
|
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
|
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
|
|
|
try:
|
|
least_loaded_cluster = await get_least_loaded_cluster()
|
|
tasks = [
|
|
asyncio.create_task(
|
|
create_key_on_cluster(least_loaded_cluster, tg_id, client_id, email, expiry_timestamp, plan)
|
|
)
|
|
]
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
|
|
await store_key(
|
|
tg_id,
|
|
client_id,
|
|
email,
|
|
expiry_timestamp,
|
|
public_link,
|
|
least_loaded_cluster,
|
|
session,
|
|
)
|
|
await update_trial(tg_id, 1, session)
|
|
logger.info(f"[Database] Ключ сохранён в базе данных для пользователя {tg_id}")
|
|
except Exception as e:
|
|
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
|
|
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
|
|
if target_message:
|
|
await edit_or_send_message(
|
|
target_message=target_message, text=error_message, reply_markup=None, media_path=None
|
|
)
|
|
else:
|
|
await bot.send_message(chat_id=tg_id, text=error_message)
|
|
return
|
|
|
|
builder = InlineKeyboardBuilder()
|
|
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
|
|
if CONNECT_PHONE_BUTTON:
|
|
builder.row(InlineKeyboardButton(text="📱 Подключить телефон", callback_data=f"connect_phone|{key_name}"))
|
|
else:
|
|
builder.row(
|
|
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
|
|
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
|
|
)
|
|
builder.row(
|
|
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{public_link}"),
|
|
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{public_link}"),
|
|
)
|
|
builder.row(
|
|
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
|
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
|
|
)
|
|
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
|
|
|
expiry_time_local = expiry_time.replace(tzinfo=None).astimezone(moscow_tz)
|
|
remaining_time = expiry_time_local - datetime.now(moscow_tz)
|
|
days = remaining_time.days
|
|
key_message_text = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
|
|
|
|
if target_message:
|
|
await edit_or_send_message(
|
|
target_message=target_message, text=key_message_text, reply_markup=builder.as_markup(), media_path=None
|
|
)
|
|
else:
|
|
await bot.send_message(
|
|
chat_id=tg_id,
|
|
text=key_message_text,
|
|
reply_markup=builder.as_markup(),
|
|
)
|
|
|
|
if state:
|
|
await state.clear()
|
|
|
|
|
|
@router.callback_query(F.data.startswith("change_location|"))
|
|
async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
|
try:
|
|
data = callback_query.data.split("|")
|
|
if len(data) < 2:
|
|
await callback_query.answer("❌ Некорректные данные", show_alert=True)
|
|
return
|
|
|
|
old_key_name = data[1]
|
|
record = await get_key_details(old_key_name, session)
|
|
if not record:
|
|
await callback_query.answer("❌ Ключ не найден", show_alert=True)
|
|
return
|
|
|
|
expiry_timestamp = record["expiry_time"]
|
|
ts = int(expiry_timestamp / 1000)
|
|
|
|
servers = await session.fetch("SELECT server_name FROM servers")
|
|
countries = [row["server_name"] for row in servers]
|
|
logger.info(f"Доступные страны для смены локации: {countries}")
|
|
|
|
builder = InlineKeyboardBuilder()
|
|
for country in countries:
|
|
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
|
|
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
|
|
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view_key|{old_key_name}"))
|
|
|
|
await edit_or_send_message(
|
|
target_message=callback_query.message,
|
|
text="🌍 Пожалуйста, выберите новую локацию для вашей подписки:",
|
|
reply_markup=builder.as_markup(),
|
|
media_path=None,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
|
|
await callback_query.answer("❌ Ошибка смены локации. Попробуйте снова.", show_alert=True)
|
|
|
|
|
|
@router.callback_query(F.data.startswith("select_country|"))
|
|
async def handle_country_selection(callback_query: CallbackQuery, session: Any):
|
|
"""
|
|
Обрабатывает выбор страны.
|
|
Формат callback data:
|
|
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
|
|
Если передан old_key_name – значит, происходит смена локации.
|
|
"""
|
|
data = callback_query.data.split("|")
|
|
if len(data) < 3:
|
|
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
|
|
return
|
|
|
|
selected_country = data[1]
|
|
try:
|
|
ts = int(data[2])
|
|
except ValueError:
|
|
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
|
|
return
|
|
|
|
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
|
|
|
|
old_key_name = data[3] if len(data) > 3 else None
|
|
|
|
tg_id = callback_query.from_user.id
|
|
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
|
|
logger.info(f"Получено время истечения (timestamp): {ts}")
|
|
|
|
await finalize_key_creation(tg_id, expiry_time, selected_country, None, session, callback_query, old_key_name)
|
|
|
|
|
|
async def finalize_key_creation(
|
|
tg_id: int,
|
|
expiry_time: datetime,
|
|
selected_country: str,
|
|
state: FSMContext | None,
|
|
session: Any,
|
|
callback_query: CallbackQuery,
|
|
old_key_name: str = None,
|
|
):
|
|
"""Финализирует создание ключа с выбором стран."""
|
|
|
|
if not await check_connection_exists(tg_id):
|
|
await add_connection(tg_id, balance=0.0, trial=0, session=session)
|
|
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
|
|
|
|
expiry_time = expiry_time.astimezone(moscow_tz)
|
|
|
|
if old_key_name:
|
|
key_name = old_key_name
|
|
else:
|
|
while True:
|
|
key_name = generate_random_email()
|
|
existing_key = await get_key_details(key_name, session)
|
|
if not existing_key:
|
|
break
|
|
logger.warning(f"Key name '{key_name}' already exists for user {tg_id}. Generating a new one.")
|
|
|
|
client_id = str(uuid.uuid4())
|
|
email = key_name.lower()
|
|
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
|
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
|
|
|
try:
|
|
server_info = await session.fetchrow(
|
|
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
|
|
selected_country,
|
|
)
|
|
|
|
if not server_info:
|
|
raise ValueError(f"Сервер {selected_country} не найден.")
|
|
|
|
if old_key_name:
|
|
old_key_details = await get_key_details(old_key_name, session)
|
|
old_client_id = old_key_details.get("client_id") if old_key_details else None
|
|
old_email = old_key_details.get("email") if old_key_details else None
|
|
old_server_id = old_key_details.get("server_id") if old_key_details else None
|
|
|
|
if old_client_id and old_email and old_server_id:
|
|
old_server_info = await session.fetchrow(
|
|
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
|
|
old_server_id,
|
|
)
|
|
|
|
if old_server_info:
|
|
xui = AsyncApi(
|
|
old_server_info["api_url"],
|
|
username=ADMIN_USERNAME,
|
|
password=ADMIN_PASSWORD,
|
|
logger=logger,
|
|
)
|
|
deletion_success = await delete_client(
|
|
xui,
|
|
old_server_info["inbound_id"],
|
|
old_email,
|
|
old_client_id,
|
|
)
|
|
if not deletion_success:
|
|
raise ValueError(f"Не удалось удалить клиента с сервера {old_server_id}.")
|
|
|
|
semaphore = asyncio.Semaphore(2)
|
|
await create_client_on_server(
|
|
server_info=server_info,
|
|
tg_id=tg_id,
|
|
client_id=client_id,
|
|
email=email,
|
|
expiry_timestamp=expiry_timestamp,
|
|
semaphore=semaphore,
|
|
)
|
|
|
|
logger.info(f"Key created on server {selected_country} for user {tg_id}.")
|
|
|
|
if old_key_name:
|
|
await session.execute(
|
|
"""
|
|
UPDATE keys
|
|
SET client_id = $1, email = $2, expiry_time = $3, key = $4, server_id = $5
|
|
WHERE tg_id = $6 AND email = $7
|
|
""",
|
|
client_id,
|
|
email,
|
|
expiry_timestamp,
|
|
public_link,
|
|
selected_country,
|
|
tg_id,
|
|
old_key_name,
|
|
)
|
|
else:
|
|
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
|
|
await session.execute(
|
|
"""
|
|
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, server_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
""",
|
|
tg_id,
|
|
client_id,
|
|
email,
|
|
created_at,
|
|
expiry_timestamp,
|
|
public_link,
|
|
selected_country,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error while creating the key for user {tg_id}: {e}")
|
|
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова.")
|
|
return
|
|
|
|
builder = InlineKeyboardBuilder()
|
|
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
|
|
if CONNECT_PHONE_BUTTON:
|
|
builder.row(InlineKeyboardButton(text="📱 Подключить телефон", callback_data=f"connect_phone|{key_name}"))
|
|
else:
|
|
builder.row(
|
|
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
|
|
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
|
|
)
|
|
builder.row(
|
|
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{public_link}"),
|
|
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{public_link}"),
|
|
)
|
|
builder.row(
|
|
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
|
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
|
|
)
|
|
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
|
|
|
remaining_time = expiry_time - datetime.now(moscow_tz)
|
|
days = remaining_time.days
|
|
key_message_text = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
|
|
|
|
await edit_or_send_message(
|
|
target_message=callback_query.message, text=key_message_text, reply_markup=builder.as_markup(), media_path=None
|
|
)
|
|
|
|
if state:
|
|
await state.clear()
|