Улучшение

This commit is contained in:
2026-01-08 19:36:50 +03:00
parent 14f975d4a0
commit 2d95a32005
2 changed files with 298 additions and 108 deletions

View File

@@ -11,6 +11,7 @@ CONFIG = {
"DATABASE_URL": os.getenv("DATABASE_URL"),
"ADMIN_IDS": [int(i.strip()) for i in os.getenv("ADMIN_IDS", "").split(",") if i.strip()],
"PROVIDER_TOKEN": os.getenv("PROVIDER_TOKEN", ""),
"BASE_URL": os.getenv("BASE_URL"), # Внешний домен (например, https://vpn.example.com)
}
PLANS = {

343
main.py
View File

@@ -5,6 +5,8 @@ from typing import Optional, Dict, Any
import json
import random
import string
from qrcode import QRCode
import io
from aiogram import Bot, Dispatcher, Router, F
from aiogram.filters import Command, StateFilter
@@ -13,7 +15,8 @@ from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import (
Message, CallbackQuery, InlineKeyboardMarkup,
InlineKeyboardButton, LabeledPrice, PreCheckoutQuery
InlineKeyboardButton, LabeledPrice, PreCheckoutQuery,
BufferedInputFile
)
import aiohttp
import aiosqlite
@@ -67,26 +70,36 @@ class MarzbanAPI:
await self.login()
headers = {"Authorization": f"Bearer {self.token}"}
url = f"{self.url}/api{endpoint}"
logger.debug(f"Marzban Request: {method} {url} Payload: {kwargs.get('json')}")
async with self.session.request(
method, f"{self.url}/api{endpoint}", headers=headers, **kwargs
method, url, headers=headers, **kwargs
) as resp:
data = await resp.json()
logger.info(f"Marzban Response [{resp.status}]: {data}")
if resp.status == 401:
await self.login()
headers = {"Authorization": f"Bearer {self.token}"}
async with self.session.request(
method, f"{self.url}/api{endpoint}", headers=headers, **kwargs
method, url, headers=headers, **kwargs
) as retry_resp:
return await retry_resp.json()
return await resp.json()
retry_data = await retry_resp.json()
logger.info(f"Marzban Retry Response [{retry_resp.status}]: {retry_data}")
return retry_data
return data
async def create_user(self, username: str, data_limit: int, expire_days: int):
expire_timestamp = int((datetime.now() + timedelta(days=expire_days)).timestamp())
payload = {
"username": username,
"proxies": {
"vless": {},
"vmess": {}
"vless": {}
},
"inbounds": {}, # Разрешить все входящие
"excluded_inbounds": {}, # Ничего не исключать
"data_limit": data_limit * 1024 * 1024 * 1024, # GB to bytes
"expire": expire_timestamp,
"status": "active"
@@ -101,6 +114,7 @@ class MarzbanAPI:
payload = {
"data_limit": data_limit * 1024 * 1024 * 1024,
"expire": expire_timestamp,
"excluded_inbounds": {}, # Снимаем ограничения при продлении
"status": "active"
}
return await self._request("PUT", f"/user/{username}", json=payload)
@@ -267,18 +281,17 @@ class Database:
code, discount, uses, created_by
)
async def use_promo_code(self, code: str):
promo = await self.fetchrow(
async def get_promo_code(self, code: str):
return await self.fetchrow(
"SELECT * FROM promo_codes WHERE code = $1 AND uses_left > 0",
code
)
if not promo:
return None
async def decrement_promo_usage(self, code: str):
await self.execute(
"UPDATE promo_codes SET uses_left = uses_left - 1 WHERE code = $1",
code
)
return promo['discount']
async def add_payment(self, user_id: int, plan: str, amount: int, promo_code: str = None):
await self.execute(
@@ -346,6 +359,13 @@ async def cmd_start(message: Message, state: FSMContext):
user_id = message.from_user.id
user = await db.get_user(user_id)
# Автоматическая регистрация админа
if not user and user_id in CONFIG["ADMIN_IDS"]:
marzban_username = f"user_{user_id}"
await db.create_user(user_id, message.from_user.username, marzban_username, invited_by=None)
user = await db.get_user(user_id)
await message.answer("✅ Администратор автоматически зарегистрирован.")
if user:
is_admin = user_id in CONFIG["ADMIN_IDS"]
await message.answer(
@@ -385,7 +405,15 @@ async def process_invite_code(message: Message, state: FSMContext):
async def show_subscription(callback: CallbackQuery):
user = await db.get_user(callback.from_user.id)
if not user['subscription_until'] or user['subscription_until'] < datetime.now():
sub_until = user['subscription_until']
if isinstance(sub_until, str):
try:
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S')
except ValueError:
# Try with microseconds if previous format fails
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S.%f')
if not sub_until or sub_until < datetime.now():
await callback.message.edit_text(
"У вас нет активной подписки.\n\n"
"Приобретите подписку, чтобы начать пользоваться VPN.",
@@ -395,44 +423,109 @@ async def show_subscription(callback: CallbackQuery):
try:
marzban_user = await marzban.get_user(user['marzban_username'])
used_traffic = marzban_user.get('used_traffic', 0) / (1024**3) # bytes to GB
# Если пользователя нет в панели, но подписка активна - пробуем создать заново
if isinstance(marzban_user, dict) and marzban_user.get('detail') == 'User not found':
logger.warning(f"User {user['marzban_username']} not found in Marzban, restoring...")
await marzban.create_user(
user['marzban_username'],
user['data_limit'] or 50, # Лимит из базы или 50 по умолчанию
30 # Срок не важен, он обновится при следующей оплате
)
marzban_user = await marzban.get_user(user['marzban_username'])
used_traffic = marzban_user.get('used_traffic', 0) / (1024**3)
sub_url = marzban_user.get('subscription_url', 'Генерируется...')
if sub_url and sub_url.startswith('/'):
# Используем BASE_URL если он есть, иначе MARZBAN_URL
base = CONFIG.get('BASE_URL') or CONFIG['MARZBAN_URL']
sub_url = f"{base.rstrip('/')}{sub_url}"
info_text = (
f"📊 Ваша подписка:\n\n"
f"⏰ Действует до: {user['subscription_until'].strftime('%d.%m.%Y %H:%M')}\n"
f"⏰ Действует до: {sub_until.strftime('%d.%m.%Y %H:%M')}\n"
f"📦 Лимит трафика: {user['data_limit']} ГБ\n"
f"📊 Использовано: {used_traffic:.2f} ГБ\n\n"
f"🔗 Конфигурация:\n"
f"`{marzban_user.get('subscription_url', 'Генерируется...')}`"
f"🎫 Ссылка на подписку (рекомендуется):\n"
f"`{sub_url}`"
)
except Exception as e:
logger.error(f"Error getting user info: {e}")
info_text = "⚠️ Ошибка получения данных. Попробуйте позже."
await callback.message.edit_text(
info_text,
try:
# Генерация QR-кода
qr = QRCode(version=1, box_size=10, border=5)
qr.add_data(sub_url)
qr.make(fit=True)
qr_img = qr.make_image(fill_color="black", back_color="white")
# Сохранение в буфер
img_buffer = io.BytesIO()
qr_img.save(img_buffer)
img_buffer.seek(0)
qr_file = BufferedInputFile(img_buffer.getvalue(), filename="subscription_qr.png")
# Если это текстовое сообщение - удаляем и шлем фото
# Если это уже фото - пробуем edit_media (но проще удалить и прислать новое для чистоты)
await callback.message.delete()
await callback.message.answer_photo(
photo=qr_file,
caption=info_text,
reply_markup=main_keyboard(callback.from_user.id in CONFIG["ADMIN_IDS"]),
parse_mode="Markdown"
)
except Exception as e:
logger.error(f"Error sending subscription photo: {e}")
# В случае ошибки - шлем текст как раньше
await callback.message.answer(info_text, reply_markup=main_keyboard(callback.from_user.id in CONFIG["ADMIN_IDS"]), parse_mode="Markdown")
@router.callback_query(F.data == "buy_subscription")
async def show_plans(callback: CallbackQuery):
await callback.message.edit_text(
text = (
"💎 Выберите тарифный план:\n\n"
"Все планы включают:\n"
"• Безлимитная скорость\n"
"• Поддержка всех устройств\n"
"• Техподдержка 24/7",
reply_markup=plans_keyboard()
"• Техподдержка 24/7"
)
kb = plans_keyboard()
if callback.message.photo:
await callback.message.delete()
await callback.message.answer(text, reply_markup=kb)
else:
await callback.message.edit_text(text, reply_markup=kb)
@router.callback_query(F.data.startswith("plan_"))
async def process_plan_selection(callback: CallbackQuery, state: FSMContext):
plan_id = callback.data.split("_")[1]
plan_id = callback.data.replace("plan_", "", 1)
plan = PLANS[plan_id]
await state.update_data(selected_plan=plan_id)
data = await state.get_data()
# Если промокод уже активирован
if 'promo_code' in data and 'discount' in data:
discount = data['discount']
new_price = int(plan['price'] * (100 - discount) / 100)
await state.update_data(final_price=new_price)
await callback.message.edit_text(
f"Вы выбрали: {plan['name']}\n"
f"Цена без скидки: {plan['price']}\n"
f"✅ Применен промокод: {discount}%\n"
f"Итого к оплате: {new_price}\n\n"
f"📦 Трафик: {plan['data_limit']} ГБ\n"
f"⏰ Период: {plan['days']} дней",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Оплатить", callback_data="pay_now")],
[InlineKeyboardButton(text="❌ Сбросить промокод", callback_data="reset_promo")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="buy_subscription")],
])
)
else:
await callback.message.edit_text(
f"Вы выбрали: {plan['name']}\n"
f"Стоимость: {plan['price']}\n\n"
@@ -454,15 +547,21 @@ async def ask_promo(callback: CallbackQuery, state: FSMContext):
@router.message(PromoStates.waiting_for_promo)
async def process_promo(message: Message, state: FSMContext):
promo_code = message.text.strip().upper()
discount = await db.use_promo_code(promo_code)
promo = await db.get_promo_code(promo_code)
if promo:
discount = promo['discount']
await state.update_data(promo_code=promo_code, discount=discount)
data = await state.get_data()
# Если план уже выбран (переход из покупки)
if 'selected_plan' in data:
plan_id = data['selected_plan']
plan = PLANS[plan_id]
if discount:
new_price = int(plan['price'] * (100 - discount) / 100)
await state.update_data(promo_code=promo_code, final_price=new_price)
await state.update_data(final_price=new_price)
await message.answer(
f"✅ Промокод применен! Скидка: {discount}%\n"
f"Новая цена: {new_price}",
@@ -471,22 +570,117 @@ async def process_promo(message: Message, state: FSMContext):
[InlineKeyboardButton(text="◀️ Назад", callback_data="buy_subscription")],
])
)
# Если план не выбран (переход из главного меню)
else:
await message.answer(
f"✅ Промокод активирован! Скидка: {discount}%\n"
"Теперь выберите тариф:",
reply_markup=plans_keyboard()
)
else:
await message.answer(
"❌ Промокод недействителен или исчерпан.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="enter_promo")],
[InlineKeyboardButton(text="💳 Оплатить без промокода", callback_data="pay_now")],
[InlineKeyboardButton(text="◀️ Отмена", callback_data="back_to_main")],
])
)
await state.set_state(None)
@router.callback_query(F.data == "reset_promo")
async def reset_promo(callback: CallbackQuery, state: FSMContext):
await state.update_data(promo_code=None, discount=None, final_price=None)
# Перезагружаем выбор плана
data = await state.get_data()
if 'selected_plan' in data:
# Имитируем повторный выбор плана для обновления текста
callback.data = f"plan_{data['selected_plan']}"
await process_plan_selection(callback, state)
else:
await back_to_main(callback)
async def grant_subscription(user_id: int, plan_id: str, promo_code: str, amount: int):
plan = PLANS[plan_id]
user = await db.get_user(user_id)
sub_until = user['subscription_until']
if isinstance(sub_until, str):
try:
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S')
except ValueError:
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S.%f')
# Marzban
try:
marzban_username = user['marzban_username']
resp = None
if sub_until and sub_until > datetime.now():
logger.info(f"Attempting to modify existing user: {marzban_username}")
resp = await marzban.modify_user(
marzban_username,
plan['data_limit'],
plan['days']
)
# Если пользователь не найден в Marzban (хотя в БД бота он есть)
if isinstance(resp, dict) and resp.get('detail') == 'User not found':
logger.info(f"User {marzban_username} missing in Marzban, re-creating...")
resp = await marzban.create_user(
marzban_username,
plan['data_limit'],
plan['days']
)
else:
logger.info(f"Creating/Reactivating user: {marzban_username}")
resp = await marzban.create_user(
marzban_username,
plan['data_limit'],
plan['days']
)
except Exception as e:
logger.error(f"Marzban error in grant_subscription: {e}")
# DB
await db.update_subscription(user_id, plan['days'], plan['data_limit'])
await db.add_payment(
user_id,
plan_id,
amount,
promo_code
)
if promo_code:
await db.decrement_promo_usage(promo_code)
return plan
@router.callback_query(F.data == "pay_now")
async def process_payment(callback: CallbackQuery, state: FSMContext):
data = await state.get_data()
plan_id = data['selected_plan']
plan_id = data.get('selected_plan')
if not plan_id:
await callback.answer("❌ Сессия истекла. Пожалуйста, выберите тариф снова.", show_alert=True)
await show_plans(callback)
return
plan = PLANS[plan_id]
final_price = data.get('final_price', plan['price'])
final_price = int(data.get('final_price', plan['price']))
promo_code = data.get('promo_code')
if final_price <= 0:
await grant_subscription(callback.from_user.id, plan_id, promo_code, 0)
await callback.message.edit_text(
f"✅ Подписка активирована бесплатно!\n\n"
f"План: {plan['name']}\n"
f"Срок: {plan['days']} дней\n"
f"Трафик: {plan['data_limit']} ГБ\n\n"
f"Настройте подключение в меню: 📊 Моя подписка",
reply_markup=main_keyboard(callback.from_user.id in CONFIG["ADMIN_IDS"])
)
await state.clear()
return
# Создаем инвойс для Telegram Stars
await callback.message.answer_invoice(
@@ -508,36 +702,14 @@ async def pre_checkout_handler(pre_checkout_query: PreCheckoutQuery):
async def successful_payment(message: Message):
payment = message.successful_payment
plan_id, promo_code = payment.invoice_payload.split(":")
plan = PLANS[plan_id]
if not promo_code:
promo_code = None
user = await db.get_user(message.from_user.id)
# Обновляем или создаем пользователя в Marzban
try:
if user['subscription_until'] and user['subscription_until'] > datetime.now():
# Продлеваем существующую подписку
await marzban.modify_user(
user['marzban_username'],
plan['data_limit'],
plan['days']
)
else:
# Создаем новую подписку
await marzban.create_user(
user['marzban_username'],
plan['data_limit'],
plan['days']
)
except Exception as e:
logger.error(f"Marzban error: {e}")
# Обновляем БД
await db.update_subscription(message.from_user.id, plan['days'], plan['data_limit'])
await db.add_payment(
plan = await grant_subscription(
message.from_user.id,
plan_id,
payment.total_amount,
promo_code if promo_code else None
promo_code,
payment.total_amount
)
await message.answer(
@@ -555,10 +727,14 @@ async def admin_panel(callback: CallbackQuery):
await callback.answer("❌ Доступ запрещен", show_alert=True)
return
await callback.message.edit_text(
"👑 Панель администратора",
reply_markup=admin_keyboard()
)
text = "👑 Панель администратора"
kb = admin_keyboard()
if callback.message.photo:
await callback.message.delete()
await callback.message.answer(text, reply_markup=kb)
else:
await callback.message.edit_text(text, reply_markup=kb)
@router.callback_query(F.data == "admin_stats")
async def admin_stats(callback: CallbackQuery):
@@ -678,18 +854,28 @@ async def admin_promo_uses(message: Message, state: FSMContext):
@router.callback_query(F.data == "back_to_main")
async def back_to_main(callback: CallbackQuery):
is_admin = callback.from_user.id in CONFIG["ADMIN_IDS"]
await callback.message.edit_text(
"Главное меню:",
reply_markup=main_keyboard(is_admin)
)
text = "Главное меню:"
kb = main_keyboard(is_admin)
if callback.message.photo:
await callback.message.delete()
await callback.message.answer(text, reply_markup=kb)
else:
await callback.message.edit_text(text, reply_markup=kb)
@router.callback_query(F.data == "use_promo")
async def use_promo_callback(callback: CallbackQuery):
await callback.message.edit_text(
"Эта функция доступна при покупке подписки.\n"
"Сначала выберите тариф, затем можно будет ввести промокод.",
reply_markup=main_keyboard(callback.from_user.id in CONFIG["ADMIN_IDS"])
)
async def use_promo_callback(callback: CallbackQuery, state: FSMContext):
text = "Введите промокод для активации скидки:"
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="◀️ Отмена", callback_data="back_to_main")]
])
if callback.message.photo:
await callback.message.delete()
await callback.message.answer(text, reply_markup=kb)
else:
await callback.message.edit_text(text, reply_markup=kb)
await state.set_state(PromoStates.waiting_for_promo)
@router.callback_query(F.data == "help")
async def help_handler(callback: CallbackQuery):
@@ -706,14 +892,17 @@ async def help_handler(callback: CallbackQuery):
"• Windows: v2rayN, Nekoray\n"
"• macOS: V2rayU, ClashX\n\n"
"❓ Возникли проблемы?\n"
"Напишите администратору: @your_admin"
"Напишите администратору: @hoshimach1"
)
await callback.message.edit_text(
help_text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="◀️ Назад", callback_data="back_to_main")]
])
)
if callback.message.photo:
await callback.message.delete()
await callback.message.answer(help_text, reply_markup=kb)
else:
await callback.message.edit_text(help_text, reply_markup=kb)
@router.callback_query(F.data == "admin_broadcast")
async def admin_broadcast_start(callback: CallbackQuery, state: FSMContext):
@@ -760,7 +949,7 @@ async def admin_broadcast_send(message: Message, state: FSMContext):
await state.clear()
# Команда для получения своего ID
@router.message(Command("myid"))
@router.message(Command("myid"), StateFilter("*"))
async def cmd_myid(message: Message):
await message.answer(f"Ваш Telegram ID: `{message.from_user.id}`", parse_mode="Markdown")