Merge branch 'main' into dev
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
@@ -6,12 +6,12 @@
|
||||
|
||||
- [v1.4](https://github.com/Vladless/Solo_bot/releases/tag/v1.4) — бот для продажи ключей VLESS:
|
||||
|
||||

|
||||

|
||||
|
||||
- [v2.3.1](https://github.com/Vladless/Solo_bot/releases/tag/v2.3.1) — стабильная версия подписок вместо ключей с
|
||||
кнопками автодобавления в приложение:
|
||||
|
||||

|
||||

|
||||
|
||||
- [v3.1](https://github.com/Vladless/Solo_bot/releases/tag/v3.1) — версия бота со значительным расширением возможностей.
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ async def add_blocked_user(tg_id: int, conn: asyncpg.Connection):
|
||||
|
||||
|
||||
async def init_db(file_path: str = "assets/schema.sql"):
|
||||
with open(file_path) as file:
|
||||
with open(file_path, mode="r") as file:
|
||||
sql_content = file.read()
|
||||
|
||||
statements = [stmt.strip() for stmt in sql_content.split(";") if stmt.strip()]
|
||||
@@ -792,18 +792,18 @@ async def get_referral_stats(referrer_tg_id: int):
|
||||
WHERE rl.level < {MAX_REFERRAL_LEVELS}
|
||||
)
|
||||
SELECT
|
||||
SUM(p.amount * CASE
|
||||
{" ".join([f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]}" for level in REFERRAL_BONUS_PERCENTAGES])}
|
||||
ELSE 0
|
||||
END) AS total_bonus
|
||||
COALESCE(SUM(p.amount * (
|
||||
CASE
|
||||
{" ".join([f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]}" for level in REFERRAL_BONUS_PERCENTAGES])}
|
||||
ELSE 0
|
||||
END)), 0) AS total_bonus
|
||||
FROM referral_levels rl
|
||||
JOIN payments p ON rl.referred_tg_id = p.tg_id
|
||||
WHERE p.status = 'success'
|
||||
WHERE p.status = 'success' AND rl.level <= {MAX_REFERRAL_LEVELS}
|
||||
""",
|
||||
referrer_tg_id,
|
||||
)
|
||||
|
||||
total_referral_bonus = total_referral_bonus or 0
|
||||
logger.debug(
|
||||
f"Получена общая сумма бонусов от рефералов: {total_referral_bonus}"
|
||||
)
|
||||
|
||||
@@ -3,3 +3,4 @@ MY_GIFTS = "🎁 Мои подарки"
|
||||
PROFILE = "👤 Личный кабинет"
|
||||
BACK = "🔙 Назад"
|
||||
GIFTS_ABOUT = "<b>Дарите подарки и следите, чтобы они дошли до адресата! 🎄</b>"
|
||||
SHARE_GIFT = "🎁 Поделиться подарком"
|
||||
|
||||
@@ -20,6 +20,7 @@ from config import (
|
||||
RENEWAL_PRICES,
|
||||
SUPPORT_CHAT_URL,
|
||||
TRIAL_TIME,
|
||||
USE_COUNTRY_SELECTION,
|
||||
USE_NEW_PAYMENT_FLOW,
|
||||
)
|
||||
from database import (
|
||||
@@ -37,7 +38,7 @@ from handlers.buttons.add_subscribe import (
|
||||
PC_BUTTON,
|
||||
TV_BUTTON,
|
||||
)
|
||||
from handlers.keys.key_utils import create_key_on_cluster
|
||||
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
|
||||
@@ -132,21 +133,21 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any):
|
||||
duration_days = int(plan_id) * 30
|
||||
balance = await get_balance(tg_id)
|
||||
|
||||
await save_temporary_data(
|
||||
session,
|
||||
tg_id,
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"plan_id": plan_id,
|
||||
"plan_price": plan_price,
|
||||
"duration_days": duration_days,
|
||||
"required_amount": max(0, plan_price - balance),
|
||||
},
|
||||
)
|
||||
|
||||
if balance < plan_price:
|
||||
required_amount = plan_price - balance
|
||||
|
||||
await save_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":
|
||||
@@ -166,9 +167,9 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any):
|
||||
)
|
||||
return
|
||||
|
||||
await update_balance(tg_id, -plan_price)
|
||||
expiry_time = datetime.utcnow() + timedelta(days=duration_days)
|
||||
await create_key(tg_id, expiry_time, None, session, callback_query)
|
||||
await update_balance(tg_id, -plan_price)
|
||||
|
||||
|
||||
async def create_key(
|
||||
@@ -182,9 +183,65 @@ async def create_key(
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
expiry_time = expiry_time.astimezone(moscow_tz)
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
logger.info("[Country Selection] USE_COUNTRY_SELECTION включен.")
|
||||
|
||||
logger.info("[Country Selection] Получение наименее загруженного кластера.")
|
||||
least_loaded_cluster = await get_least_loaded_cluster()
|
||||
logger.info(f"[Country Selection] Наименее загруженный кластер: {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()
|
||||
for country in countries:
|
||||
callback_data = f"select_country|{country}|{expiry_time.isoformat()}"
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=country, callback_data=callback_data
|
||||
)
|
||||
)
|
||||
logger.info(f"[Country Selection] Добавлена кнопка для страны: {country} с callback_data: {callback_data}")
|
||||
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="profile"))
|
||||
logger.info("[Country Selection] Добавлена кнопка '🔙 Назад'.")
|
||||
|
||||
if isinstance(message_or_query, Message):
|
||||
logger.info("[Country Selection] Сообщение пользователя - тип Message.")
|
||||
await message_or_query.answer(
|
||||
"🌍 Пожалуйста, выберите страну для вашего ключа:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
logger.info("[Country Selection] Сообщение отправлено с выбором страны.")
|
||||
elif isinstance(message_or_query, CallbackQuery):
|
||||
logger.info("[Country Selection] Сообщение пользователя - тип CallbackQuery.")
|
||||
await message_or_query.message.answer(
|
||||
"🌍 Пожалуйста, выберите страну для вашего ключа:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
logger.info("[Country Selection] Сообщение отправлено с выбором страны.")
|
||||
elif tg_id is not None:
|
||||
logger.info("[Country Selection] Использование tg_id для отправки сообщения.")
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text="🌍 Пожалуйста, выберите страну для вашего ключа:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
logger.info(f"[Country Selection] Сообщение отправлено напрямую в чат {tg_id}.")
|
||||
else:
|
||||
logger.error("[Country Selection] Невозможно определить идентификатор чата. Сообщение не отправлено.")
|
||||
|
||||
logger.info("[Country Selection] Возврат из функции.")
|
||||
return
|
||||
|
||||
while True:
|
||||
key_name = generate_random_email()
|
||||
logger.info(f"Generated random key name for user {tg_id}: {key_name}")
|
||||
logger.info(f"[Key Generation] Сгенерировано имя ключа: {key_name} для пользователя {tg_id}")
|
||||
|
||||
existing_key = await session.fetchrow(
|
||||
"SELECT * FROM keys WHERE email = $1 AND tg_id = $2",
|
||||
@@ -193,13 +250,11 @@ async def create_key(
|
||||
)
|
||||
if not existing_key:
|
||||
break
|
||||
logger.warning(
|
||||
f"Key name '{key_name}' already exists for user {tg_id}. Generating a new one."
|
||||
)
|
||||
logger.warning(f"[Key Generation] Имя ключа {key_name} уже существует. Генерация нового.")
|
||||
|
||||
client_id = str(uuid.uuid4())
|
||||
email = key_name.lower()
|
||||
expiry_timestamp = int(expiry_time.astimezone(moscow_tz).timestamp() * 1000)
|
||||
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||||
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
||||
|
||||
try:
|
||||
@@ -218,7 +273,7 @@ async def create_key(
|
||||
]
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
logger.info(f"Key created on cluster {least_loaded_cluster} for user {tg_id}.")
|
||||
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
|
||||
|
||||
await store_key(
|
||||
tg_id,
|
||||
@@ -229,9 +284,10 @@ async def create_key(
|
||||
least_loaded_cluster,
|
||||
session,
|
||||
)
|
||||
logger.info(f"[Database] Ключ сохранён в базе данных для пользователя {tg_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error while creating the key for user {tg_id} on cluster: {e}")
|
||||
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
|
||||
|
||||
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
|
||||
if isinstance(message_or_query, Message):
|
||||
@@ -271,3 +327,121 @@ async def create_key(
|
||||
|
||||
if state:
|
||||
await state.clear()
|
||||
logger.info(f"[FSM] Состояние пользователя {tg_id} очищено")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_country|"))
|
||||
async def handle_country_selection(callback_query: CallbackQuery, session: Any):
|
||||
"""Обработчик выбора страны."""
|
||||
data = callback_query.data.split("|")
|
||||
selected_country = data[1]
|
||||
expiry_time_str = data[2]
|
||||
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
|
||||
logger.info(f"Получено время истечения: {expiry_time_str}")
|
||||
|
||||
try:
|
||||
expiry_time = datetime.fromisoformat(expiry_time_str)
|
||||
except ValueError:
|
||||
logger.error(f"Ошибка преобразования времени истечения: {expiry_time_str}")
|
||||
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
|
||||
return
|
||||
|
||||
await finalize_key_creation(tg_id, expiry_time, selected_country, None, session, callback_query)
|
||||
|
||||
|
||||
async def finalize_key_creation(
|
||||
tg_id: int,
|
||||
expiry_time: datetime,
|
||||
selected_country: str,
|
||||
state: FSMContext | None,
|
||||
session: Any,
|
||||
callback_query: CallbackQuery,
|
||||
):
|
||||
"""Финализирует создание ключа с выбранной страной."""
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
expiry_time = expiry_time.astimezone(moscow_tz)
|
||||
|
||||
while True:
|
||||
key_name = generate_random_email()
|
||||
logger.info(f"Generated random key name for user {tg_id}: {key_name}")
|
||||
|
||||
existing_key = await session.fetchrow(
|
||||
"SELECT * FROM keys WHERE email = $1 AND tg_id = $2",
|
||||
key_name,
|
||||
tg_id,
|
||||
)
|
||||
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} не найден.")
|
||||
|
||||
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}.")
|
||||
|
||||
await store_key(
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
public_link,
|
||||
selected_country,
|
||||
session,
|
||||
)
|
||||
|
||||
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))
|
||||
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 = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
|
||||
|
||||
await callback_query.message.answer(key_message, reply_markup=builder.as_markup())
|
||||
|
||||
if state:
|
||||
await state.clear()
|
||||
|
||||
@@ -10,9 +10,9 @@ from handlers.utils import get_least_loaded_cluster
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def create_key_on_cluster(cluster_id, tg_id, client_id, email, expiry_timestamp):
|
||||
async def create_key_on_cluster(cluster_id: str, tg_id: int, client_id: str, email: str, expiry_timestamp: int):
|
||||
"""
|
||||
Создает ключ на всех серверах указанного кластера с одинаковым sub_id и уникальным email при активном SUPERNODE.
|
||||
Создает ключ на всех серверах указанного кластера.
|
||||
"""
|
||||
try:
|
||||
servers = await get_servers_from_db()
|
||||
@@ -23,58 +23,87 @@ async def create_key_on_cluster(cluster_id, tg_id, client_id, email, expiry_time
|
||||
|
||||
semaphore = asyncio.Semaphore(2)
|
||||
|
||||
async def create_client_on_server(server_info):
|
||||
async with semaphore:
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
|
||||
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}. Пропуск."
|
||||
)
|
||||
return
|
||||
|
||||
if SUPERNODE:
|
||||
unique_email = f"{email}_{server_name.lower()}"
|
||||
sub_id = email
|
||||
else:
|
||||
unique_email = email
|
||||
sub_id = unique_email
|
||||
|
||||
await add_client(
|
||||
xui,
|
||||
client_id,
|
||||
unique_email,
|
||||
tg_id,
|
||||
limit_ip=LIMIT_IP,
|
||||
total_gb=TOTAL_GB,
|
||||
expiry_time=expiry_timestamp,
|
||||
enable=True,
|
||||
flow="xtls-rprx-vision",
|
||||
inbound_id=int(inbound_id),
|
||||
sub_id=sub_id
|
||||
)
|
||||
|
||||
if SUPERNODE:
|
||||
await asyncio.sleep(0.7)
|
||||
|
||||
if SUPERNODE:
|
||||
for server_info in cluster:
|
||||
await create_client_on_server(server_info)
|
||||
await create_client_on_server(
|
||||
server_info,
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
semaphore,
|
||||
)
|
||||
else:
|
||||
await asyncio.gather(*(create_client_on_server(server) for server in cluster))
|
||||
await asyncio.gather(
|
||||
*(
|
||||
create_client_on_server(
|
||||
server,
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
semaphore,
|
||||
)
|
||||
for server in cluster
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании ключа: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
async def create_client_on_server(
|
||||
server_info: dict,
|
||||
tg_id: int,
|
||||
client_id: str,
|
||||
email: str,
|
||||
expiry_timestamp: int,
|
||||
semaphore: asyncio.Semaphore,
|
||||
):
|
||||
"""
|
||||
Создает клиента на указанном сервере.
|
||||
"""
|
||||
async with semaphore:
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
|
||||
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}. Пропуск."
|
||||
)
|
||||
return
|
||||
|
||||
if SUPERNODE:
|
||||
unique_email = f"{email}_{server_name.lower()}"
|
||||
sub_id = email
|
||||
else:
|
||||
unique_email = email
|
||||
sub_id = unique_email
|
||||
|
||||
await add_client(
|
||||
xui,
|
||||
client_id,
|
||||
unique_email,
|
||||
tg_id,
|
||||
limit_ip=LIMIT_IP,
|
||||
total_gb=TOTAL_GB,
|
||||
expiry_time=expiry_timestamp,
|
||||
enable=True,
|
||||
flow="xtls-rprx-vision",
|
||||
inbound_id=int(inbound_id),
|
||||
sub_id=sub_id,
|
||||
)
|
||||
|
||||
if SUPERNODE:
|
||||
await asyncio.sleep(0.7)
|
||||
|
||||
async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb):
|
||||
try:
|
||||
servers = await get_servers_from_db()
|
||||
|
||||
@@ -4,6 +4,8 @@ import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
import pytz
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
@@ -13,6 +15,7 @@ from bot import bot
|
||||
from config import (
|
||||
CONNECT_ANDROID,
|
||||
CONNECT_IOS,
|
||||
DATABASE_URL,
|
||||
DOWNLOAD_ANDROID,
|
||||
DOWNLOAD_IOS,
|
||||
ENABLE_DELETE_KEY_BUTTON,
|
||||
@@ -20,6 +23,7 @@ from config import (
|
||||
PUBLIC_LINK,
|
||||
RENEWAL_PLANS,
|
||||
TOTAL_GB,
|
||||
USE_COUNTRY_SELECTION,
|
||||
USE_NEW_PAYMENT_FLOW,
|
||||
)
|
||||
from database import (
|
||||
@@ -77,7 +81,9 @@ async def process_callback_or_message_view_keys(
|
||||
try:
|
||||
records = await session.fetch(
|
||||
"""
|
||||
SELECT email, client_id FROM keys WHERE tg_id = $1
|
||||
SELECT email, client_id, expiry_time
|
||||
FROM keys
|
||||
WHERE tg_id = $1
|
||||
""",
|
||||
chat_id,
|
||||
)
|
||||
@@ -95,30 +101,50 @@ async def process_callback_or_message_view_keys(
|
||||
|
||||
def build_keys_response(records):
|
||||
"""
|
||||
Формирует сообщение и клавиатуру для устройств.
|
||||
Формирует сообщение и клавиатуру для устройств с указанием срока действия подписки.
|
||||
"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
if records:
|
||||
response_message = "<b>🔑 Список ваших подписок:</b>\n\n"
|
||||
for record in records:
|
||||
key_name = record["email"]
|
||||
expiry_time = record.get("expiry_time")
|
||||
|
||||
if expiry_time:
|
||||
expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
|
||||
formatted_date_full = expiry_date_full.strftime(
|
||||
"до %d %B %Y года, %H:%M"
|
||||
).lower()
|
||||
|
||||
formatted_date_short = expiry_date_full.strftime("до %d %B").lower()
|
||||
else:
|
||||
formatted_date_full = "без срока действия"
|
||||
formatted_date_short = "без срока действия"
|
||||
|
||||
button_text = f"{key_name} ({formatted_date_short})"
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"🔑 {key_name}", callback_data=f"view_key|{key_name}"
|
||||
text=button_text, callback_data=f"view_key|{key_name}"
|
||||
)
|
||||
)
|
||||
|
||||
response_message += f"• <b>{key_name}</b> ({formatted_date_full})\n"
|
||||
|
||||
else:
|
||||
response_message = (
|
||||
"<b>🔑 У вас пока нет подписок.</b>\n\n"
|
||||
"Вы можете создать новую подписку для подключения устройств."
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="➕ Добавить подписку", callback_data="create_key")
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
inline_keyboard = builder.as_markup()
|
||||
response_message = (
|
||||
"<b>🔑 Список ваших подписок</b>\n\n"
|
||||
"<i>👇 Выберите подписку для управления или добавьте новую для подключения дополнительного устройства:</i>"
|
||||
)
|
||||
return inline_keyboard, response_message
|
||||
|
||||
|
||||
@@ -218,7 +244,6 @@ async def process_callback_view_key(callback_query: types.CallbackQuery, session
|
||||
),
|
||||
)
|
||||
|
||||
# ✅ Добавлена проверка флага ENABLE_DELETE_KEY_BUTTON
|
||||
if ENABLE_DELETE_KEY_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
@@ -331,38 +356,27 @@ async def process_callback_renew_key(callback_query: types.CallbackQuery, sessio
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f'📅 1 месяц ({RENEWAL_PLANS["1"]["price"]} руб.)',
|
||||
callback_data=f"renew_plan|1|{client_id}",
|
||||
for plan_id, plan_details in RENEWAL_PLANS.items():
|
||||
months = plan_details["months"]
|
||||
price = plan_details["price"]
|
||||
discount = DISCOUNTS.get(plan_id, 0)
|
||||
button_text = (
|
||||
f'📅 {months} месяц{"а" if months > 1 else ""} ({price} руб.)'
|
||||
+ (f' {discount}% скидка' if discount > 0 else "")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"renew_plan|{months}|{client_id}",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f'📅 3 месяца ({RENEWAL_PLANS["3"]["price"]} руб.) {DISCOUNTS["3"]}% скидка',
|
||||
callback_data=f"renew_plan|3|{client_id}",
|
||||
text="🔙 Назад", callback_data="view_keys"
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f'📅 6 месяцев ({RENEWAL_PLANS["6"]["price"]} руб.) {DISCOUNTS["6"]}% скидка',
|
||||
callback_data=f"renew_plan|6|{client_id}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f'📅 12 месяцев ({RENEWAL_PLANS["12"]["price"]} руб.) ({DISCOUNTS["12"]}% 🔥)',
|
||||
callback_data=f"renew_plan|12|{client_id}",
|
||||
)
|
||||
)
|
||||
back_button = InlineKeyboardButton(
|
||||
text="🔙 Назад", callback_data="view_keys"
|
||||
)
|
||||
builder.row(back_button)
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
|
||||
response_message = PLAN_SELECTION_MSG.format(
|
||||
@@ -528,28 +542,58 @@ async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_g
|
||||
else:
|
||||
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
|
||||
|
||||
servers = await get_servers_from_db()
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
key_info = await conn.fetchrow(
|
||||
"""
|
||||
SELECT server_id
|
||||
FROM keys
|
||||
WHERE tg_id = $1 AND client_id = $2
|
||||
""",
|
||||
tg_id,
|
||||
client_id,
|
||||
)
|
||||
|
||||
logger.info(f"[RENEW] Запуск продления ключа для пользователя {tg_id} на {plan} мес. на всех серверах.")
|
||||
if not key_info:
|
||||
logger.error(f"[RENEW] Ключ с client_id {client_id} для пользователя {tg_id} не найден.")
|
||||
await conn.close()
|
||||
return
|
||||
|
||||
async def renew_key_on_servers():
|
||||
tasks = []
|
||||
for cluster_id in servers:
|
||||
task = asyncio.create_task(
|
||||
renew_key_in_cluster(
|
||||
cluster_id,
|
||||
email,
|
||||
client_id,
|
||||
new_expiry_time,
|
||||
total_gb,
|
||||
)
|
||||
)
|
||||
tasks.append(task)
|
||||
server_id = key_info["server_id"]
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
if USE_COUNTRY_SELECTION:
|
||||
cluster_info = await conn.fetchrow(
|
||||
"""
|
||||
SELECT cluster_name
|
||||
FROM servers
|
||||
WHERE server_name = $1
|
||||
""",
|
||||
server_id,
|
||||
)
|
||||
|
||||
if not cluster_info:
|
||||
logger.error(f"[RENEW] Сервер {server_id} не найден в таблице servers.")
|
||||
await conn.close()
|
||||
return
|
||||
|
||||
cluster_id = cluster_info["cluster_name"]
|
||||
else:
|
||||
cluster_id = server_id
|
||||
|
||||
await conn.close()
|
||||
|
||||
logger.info(f"[RENEW] Запуск продления ключа для пользователя {tg_id} на {plan} мес. в кластере {cluster_id}.")
|
||||
|
||||
async def renew_key_on_cluster():
|
||||
await renew_key_in_cluster(
|
||||
cluster_id,
|
||||
email,
|
||||
client_id,
|
||||
new_expiry_time,
|
||||
total_gb,
|
||||
)
|
||||
|
||||
await update_balance(tg_id, -cost)
|
||||
await update_key_expiry(client_id, new_expiry_time)
|
||||
await update_balance(tg_id, -cost)
|
||||
logger.info(f"[RENEW] Ключ {client_id} успешно продлён на {plan} мес. для пользователя {tg_id}.")
|
||||
|
||||
await renew_key_on_servers()
|
||||
await renew_key_on_cluster()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
import pytz
|
||||
|
||||
import asyncpg
|
||||
import pytz
|
||||
from aiogram import Bot, Router, types
|
||||
from aiogram.exceptions import TelegramForbiddenError
|
||||
from aiogram.types import BufferedInputFile
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from py3xui import AsyncApi
|
||||
|
||||
@@ -15,10 +17,10 @@ from config import (
|
||||
AUTO_RENEW_KEYS,
|
||||
DATABASE_URL,
|
||||
DEV_MODE,
|
||||
EXPIRED_KEYS_CHECK_INTERVAL,
|
||||
RENEWAL_PLANS,
|
||||
TOTAL_GB,
|
||||
TRIAL_TIME,
|
||||
EXPIRED_KEYS_CHECK_INTERVAL
|
||||
)
|
||||
from database import (
|
||||
add_blocked_user,
|
||||
@@ -184,11 +186,22 @@ async def process_10h_record(record, bot, conn):
|
||||
|
||||
await conn.execute("UPDATE keys SET notified = TRUE WHERE client_id = $1", record["client_id"])
|
||||
|
||||
image_path = os.path.join("img", "notify_10h.jpg")
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")]]
|
||||
)
|
||||
|
||||
await bot.send_message(tg_id, text=KEY_RENEWED, reply_markup=keyboard)
|
||||
if os.path.isfile(image_path):
|
||||
with open(image_path, "rb") as image_file:
|
||||
await bot.send_photo(
|
||||
tg_id,
|
||||
photo=BufferedInputFile(image_file.read(), filename="notify_10h.jpg"),
|
||||
caption=KEY_RENEWED.format(email=email),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(tg_id, text=KEY_RENEWED.format(email=email), reply_markup=keyboard)
|
||||
|
||||
logger.info(f"Уведомление об успешном продлении отправлено клиенту {tg_id}.")
|
||||
|
||||
except Exception as e:
|
||||
@@ -197,6 +210,7 @@ async def process_10h_record(record, bot, conn):
|
||||
await send_renewal_notification(bot, tg_id, email, message, conn, record["client_id"], "notified")
|
||||
|
||||
|
||||
|
||||
async def notify_24h_keys(
|
||||
bot: Bot,
|
||||
conn: asyncpg.Connection,
|
||||
@@ -222,7 +236,6 @@ async def notify_24h_keys(
|
||||
logger.info("Обработка всех уведомлений за 24 часа завершена.")
|
||||
|
||||
|
||||
|
||||
async def process_24h_record(record, bot, conn):
|
||||
tg_id = record["tg_id"]
|
||||
email = record["email"]
|
||||
@@ -259,11 +272,22 @@ async def process_24h_record(record, bot, conn):
|
||||
|
||||
await conn.execute("UPDATE keys SET notified_24h = TRUE WHERE client_id = $1", record["client_id"])
|
||||
|
||||
image_path = os.path.join("img", "notify_24h.jpg")
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")]]
|
||||
)
|
||||
|
||||
await bot.send_message(tg_id, text=KEY_RENEWED, reply_markup=keyboard)
|
||||
if os.path.isfile(image_path):
|
||||
with open(image_path, "rb") as image_file:
|
||||
await bot.send_photo(
|
||||
tg_id,
|
||||
photo=BufferedInputFile(image_file.read(), filename="notify_24h.jpg"),
|
||||
caption=KEY_RENEWED.format(email=email),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(tg_id, text=KEY_RENEWED.format(email=email), reply_markup=keyboard)
|
||||
|
||||
logger.info(f"Уведомление об успешном продлении отправлено клиенту {tg_id}.")
|
||||
|
||||
except Exception as e:
|
||||
@@ -279,7 +303,19 @@ async def send_renewal_notification(bot, tg_id, email, message, conn, client_id,
|
||||
keyboard.row(types.InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="pay"))
|
||||
keyboard.row(types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
await bot.send_message(tg_id, message, reply_markup=keyboard.as_markup())
|
||||
image_path = os.path.join("img", "notify_24h.jpg")
|
||||
|
||||
if os.path.isfile(image_path):
|
||||
with open(image_path, "rb") as image_file:
|
||||
await bot.send_photo(
|
||||
tg_id,
|
||||
photo=BufferedInputFile(image_file.read(), filename="notify_24h.jpg"),
|
||||
caption=message,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
)
|
||||
else:
|
||||
await bot.send_message(tg_id, message, reply_markup=keyboard.as_markup())
|
||||
|
||||
logger.info(f"Уведомление отправлено пользователю {tg_id}.")
|
||||
|
||||
await conn.execute(f"UPDATE keys SET {flag} = TRUE WHERE client_id = $1", client_id)
|
||||
@@ -293,7 +329,7 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
|
||||
|
||||
inactive_trial_users = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id, username FROM users
|
||||
SELECT tg_id, username, first_name, last_name FROM users
|
||||
WHERE tg_id IN (
|
||||
SELECT tg_id FROM connections
|
||||
WHERE trial = 0
|
||||
@@ -306,7 +342,16 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
|
||||
|
||||
for user in inactive_trial_users:
|
||||
tg_id = user["tg_id"]
|
||||
username = user.get("username", "Пользователь")
|
||||
|
||||
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(
|
||||
@@ -329,7 +374,7 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
message = (
|
||||
f"👋 Привет, {username}!\n\n"
|
||||
f"👋 Привет, {display_name}!\n\n"
|
||||
f"🎉 У тебя есть бесплатный пробный период на {TRIAL_TIME} дней!\n"
|
||||
"🕒 Не упусти возможность попробовать наш VPN прямо сейчас.\n\n"
|
||||
"💡 Нажми на кнопку ниже, чтобы активировать пробный доступ."
|
||||
@@ -365,7 +410,7 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
|
||||
expiring_keys = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id, client_id, expiry_time, email FROM keys
|
||||
SELECT tg_id, client_id, expiry_time, email, server_id FROM keys
|
||||
WHERE expiry_time <= $1 AND expiry_time > $2
|
||||
""",
|
||||
threshold_time,
|
||||
@@ -380,6 +425,26 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке подписки {record['client_id']}: {e}")
|
||||
|
||||
expired_keys = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id, client_id, email, server_id FROM keys
|
||||
WHERE expiry_time <= $1
|
||||
""",
|
||||
current_time,
|
||||
)
|
||||
|
||||
logger.info(f"Найдено {len(expired_keys)} истёкших ключей.")
|
||||
|
||||
for record in expired_keys:
|
||||
try:
|
||||
await delete_key_from_cluster(record["server_id"], record["email"], record["email"])
|
||||
await conn.execute(
|
||||
"DELETE FROM keys WHERE client_id = $1", record["client_id"]
|
||||
)
|
||||
logger.info(f"Удалён истёкший ключ {record['client_id']} пользователя {record['tg_id']}.")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении истёкшего ключа {record['client_id']}: {e}")
|
||||
|
||||
|
||||
async def process_key(record, bot, conn):
|
||||
tg_id = record["tg_id"]
|
||||
@@ -409,6 +474,8 @@ async def process_key(record, bot, conn):
|
||||
]
|
||||
)
|
||||
|
||||
image_path = os.path.join("img", "notify_expired.jpg")
|
||||
|
||||
try:
|
||||
if AUTO_RENEW_KEYS and balance >= RENEWAL_PLANS["1"]["price"]:
|
||||
await update_balance(tg_id, -RENEWAL_PLANS["1"]["price"])
|
||||
@@ -433,15 +500,33 @@ async def process_key(record, bot, conn):
|
||||
logger.info(f"Флаги notified сброшены для клиента {client_id}.")
|
||||
|
||||
try:
|
||||
await bot.send_message(tg_id, text=KEY_RENEWED, reply_markup=keyboard)
|
||||
if os.path.isfile(image_path):
|
||||
with open(image_path, "rb") as image_file:
|
||||
await bot.send_photo(
|
||||
tg_id,
|
||||
photo=BufferedInputFile(image_file.read(), filename="notify_expired.jpg"),
|
||||
caption = KEY_RENEWED.format(email=email),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(tg_id, text=KEY_RENEWED, reply_markup=keyboard)
|
||||
logger.info(f"Уведомление об успешном продлении отправлено клиенту {tg_id}.")
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить уведомление о продлении клиенту {tg_id}: {e}")
|
||||
|
||||
else:
|
||||
message_expired = "Ваша подписка истекла. Пополните баланс для продления."
|
||||
message_expired = f"Ваша подписка {email} истекла. Пополните баланс для продления."
|
||||
try:
|
||||
await bot.send_message(tg_id, text=message_expired, reply_markup=keyboard)
|
||||
if os.path.isfile(image_path):
|
||||
with open(image_path, "rb") as image_file:
|
||||
await bot.send_photo(
|
||||
tg_id,
|
||||
photo=BufferedInputFile(image_file.read(), filename="notify_expired.jpg"),
|
||||
caption=message_expired,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(tg_id, text=message_expired, reply_markup=keyboard)
|
||||
logger.info(f"Уведомление об истечении подписки отправлено пользователю {tg_id}.")
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить уведомление об истечении клиенту {tg_id}: {e}")
|
||||
|
||||
@@ -92,12 +92,6 @@ async def process_callback_pay_robokassa(
|
||||
callback_data=f'robokassa_amount|{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💰 Ввести свою сумму",
|
||||
callback_data="enter_custom_amount_robokassa",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="pay"))
|
||||
|
||||
key_count = await get_key_count(tg_id)
|
||||
@@ -311,4 +305,4 @@ async def handle_custom_amount_input(message: types.Message | types.CallbackQuer
|
||||
if isinstance(message, types.CallbackQuery):
|
||||
await message.message.answer(error_message)
|
||||
else:
|
||||
await message.answer(error_message)
|
||||
await message.answer(error_message)
|
||||
@@ -100,6 +100,15 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin
|
||||
)
|
||||
return await show_start_menu(message, admin, session)
|
||||
|
||||
await session.execute(
|
||||
"""
|
||||
INSERT INTO connections (tg_id, balance, trial)
|
||||
VALUES ($1, 0, 1)
|
||||
ON CONFLICT (tg_id) DO UPDATE SET trial = 1
|
||||
""",
|
||||
recipient_tg_id,
|
||||
)
|
||||
|
||||
selected_months = gift_info["selected_months"]
|
||||
expiry_time = gift_info["expiry_time"]
|
||||
expiry_time_naive = expiry_time.replace(tzinfo=None)
|
||||
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |