Улучшение взаимодействия и добавление веб-приложения

This commit is contained in:
2026-01-09 01:20:30 +03:00
parent eed252d52e
commit 2472947c1f
16 changed files with 2972 additions and 969 deletions

1
.env
View File

@@ -2,6 +2,7 @@ BOT_TOKEN=8406127231:AAG5m0Ft0UUyTW2KI-jwYniXtIRcbSdlxf8
MARZBAN_URL=http://144.31.66.170:7575/ MARZBAN_URL=http://144.31.66.170:7575/
MARZBAN_USERNAME=admin MARZBAN_USERNAME=admin
MARZBAN_PASSWORD=rY4tU8hX4nqF MARZBAN_PASSWORD=rY4tU8hX4nqF
BASE_URL=https://proxy.stellarisei.ru/
# Оставьте пустым для использования SQLite (создаст файл bot.db) # Оставьте пустым для использования SQLite (создаст файл bot.db)
# DATABASE_URL=postgresql://user:password@localhost/vpnbot # DATABASE_URL=postgresql://user:password@localhost/vpnbot
ADMIN_IDS=583602906 ADMIN_IDS=583602906

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
bot.db
.env

309
database.py Normal file
View File

@@ -0,0 +1,309 @@
import logging
import aiosqlite
import asyncpg
from datetime import datetime, timedelta
import random
import string
import re
from config import CONFIG
logger = logging.getLogger(__name__)
class Database:
def __init__(self):
self.url = CONFIG["DATABASE_URL"]
self.is_sqlite = not self.url or self.url.startswith("sqlite")
self.conn = None
self.pool = None
async def connect(self):
if self.conn or self.pool:
return
if self.is_sqlite:
db_path = "bot.db"
self.conn = await aiosqlite.connect(db_path)
self.conn.row_factory = aiosqlite.Row
logger.info(f"Using SQLite database: {db_path}")
else:
self.pool = await asyncpg.create_pool(self.url)
logger.info("Using PostgreSQL database")
await self.create_tables()
async def execute(self, query: str, *args):
if self.is_sqlite:
query = re.sub(r'\$\d+', '?', query)
async with self.conn.execute(query, args) as cursor:
await self.conn.commit()
return cursor
else:
async with self.pool.acquire() as conn:
return await conn.execute(query, *args)
async def fetchrow(self, query: str, *args):
if self.is_sqlite:
query = re.sub(r'\$\d+', '?', query)
async with self.conn.execute(query, args) as cursor:
return await cursor.fetchone()
else:
async with self.pool.acquire() as conn:
return await conn.fetchrow(query, *args)
async def fetchval(self, query: str, *args):
if self.is_sqlite:
query = re.sub(r'\$\d+', '?', query)
async with self.conn.execute(query, args) as cursor:
row = await cursor.fetchone()
return row[0] if row else None
else:
async with self.pool.acquire() as conn:
return await conn.fetchval(query, *args)
async def fetch(self, query: str, *args):
if self.is_sqlite:
query = re.sub(r'\$\d+', '?', query)
async with self.conn.execute(query, args) as cursor:
return await cursor.fetchall()
else:
async with self.pool.acquire() as conn:
return await conn.fetch(query, *args)
async def create_tables(self):
now_default = "CURRENT_TIMESTAMP" if self.is_sqlite else "NOW()"
serial_type = "INTEGER PRIMARY KEY AUTOINCREMENT" if self.is_sqlite else "SERIAL PRIMARY KEY"
queries = [
f"""CREATE TABLE IF NOT EXISTS users (
user_id BIGINT PRIMARY KEY,
username TEXT,
marzban_username TEXT UNIQUE,
subscription_until TIMESTAMP,
data_limit INTEGER,
invited_by BIGINT,
last_traffic_reset TIMESTAMP DEFAULT {now_default},
created_at TIMESTAMP DEFAULT {now_default}
)""",
f"""CREATE TABLE IF NOT EXISTS invite_codes (
code TEXT PRIMARY KEY,
created_by BIGINT,
used_by BIGINT,
used_at TIMESTAMP,
created_at TIMESTAMP DEFAULT {now_default}
)""",
f"""CREATE TABLE IF NOT EXISTS promo_codes (
code TEXT PRIMARY KEY,
discount INTEGER,
uses_left INTEGER,
expires_at TIMESTAMP NULL,
is_unlimited BOOLEAN DEFAULT 0,
bonus_days INTEGER DEFAULT 0,
is_sticky BOOLEAN DEFAULT 0,
created_by BIGINT,
created_at TIMESTAMP DEFAULT {now_default}
)""",
f"""CREATE TABLE IF NOT EXISTS payments (
id {serial_type},
user_id BIGINT,
plan TEXT,
amount INTEGER,
promo_code TEXT,
paid_at TIMESTAMP DEFAULT {now_default}
)"""
]
for q in queries:
await self.execute(q)
await self.migrate_db()
async def migrate_db(self):
# Простая миграция для SQLite/PG добавлением колонок, если их нет
try:
await self.execute("ALTER TABLE promo_codes ADD COLUMN expires_at TIMESTAMP NULL")
except Exception:
pass # Колонка уже есть
try:
await self.execute("ALTER TABLE promo_codes ADD COLUMN is_unlimited BOOLEAN DEFAULT 0")
except Exception:
pass
try:
await self.execute("ALTER TABLE promo_codes ADD COLUMN bonus_days INTEGER DEFAULT 0")
except Exception:
pass
try:
await self.execute("ALTER TABLE promo_codes ADD COLUMN is_sticky BOOLEAN DEFAULT 0")
except Exception:
pass
try:
await self.execute("ALTER TABLE users ADD COLUMN last_traffic_reset TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
except Exception:
pass
try:
await self.execute("ALTER TABLE users ADD COLUMN personal_discount INTEGER DEFAULT 0")
except Exception:
pass
async def get_user(self, user_id: int):
return await self.fetchrow("SELECT * FROM users WHERE user_id = $1", user_id)
async def get_user_by_username(self, username: str):
# Remove @ if present
username = username.lstrip('@')
return await self.fetchrow("SELECT * FROM users WHERE LOWER(username) = LOWER($1)", username)
async def search_users(self, query: str):
if query.isdigit():
# Exact ID search, but returned as list
rows = await self.fetch("SELECT * FROM users WHERE user_id = $1", int(query))
return rows
# Username partial search
term = f"%{query}%"
if self.is_sqlite:
sql = "SELECT * FROM users WHERE username LIKE $1 LIMIT 20"
else:
sql = "SELECT * FROM users WHERE username ILIKE $1 LIMIT 20"
return await self.fetch(sql, term)
async def get_all_users(self):
return await self.fetch("SELECT * FROM users")
async def create_user(self, user_id: int, username: str, marzban_username: str, invited_by: int = None):
await self.execute(
"INSERT INTO users (user_id, username, marzban_username, invited_by) VALUES ($1, $2, $3, $4)",
user_id, username, marzban_username, invited_by
)
async def update_traffic_reset_date(self, user_id: int):
now = datetime.now()
await self.execute("UPDATE users SET last_traffic_reset = $1 WHERE user_id = $2", now, user_id)
async def remove_subscription(self, user_id: int):
await self.execute("UPDATE users SET subscription_until = NULL WHERE user_id = $1", user_id)
async def update_subscription(self, user_id: int, days: int, data_limit: int):
user = await self.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:
try:
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S.%f')
except ValueError: # Fallback for ISO format
sub_until = datetime.fromisoformat(sub_until) if sub_until else None
# Fix for NoneType
if not sub_until:
sub_until = datetime.now()
if sub_until > datetime.now():
new_date = sub_until + timedelta(days=days)
else:
new_date = datetime.now() + timedelta(days=days)
# Если дней 9999+ (бесконечность), ставим далекое будущее
if days > 10000:
new_date = datetime(2099, 12, 31)
await self.execute(
"UPDATE users SET subscription_until = $1, data_limit = $2 WHERE user_id = $3",
new_date, data_limit, user_id
)
async def create_invite_code(self, created_by: int):
code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
await self.execute(
"INSERT INTO invite_codes (code, created_by) VALUES ($1, $2)",
code, created_by
)
return code
async def use_invite_code(self, code: str, user_id: int):
await self.execute(
"UPDATE invite_codes SET used_by = $1, used_at = CURRENT_TIMESTAMP WHERE code = $2",
user_id, code
)
async def check_invite_code(self, code: str):
return await self.fetchrow("SELECT * FROM invite_codes WHERE code = $1 AND used_by IS NULL", code)
async def create_promo_code(self, code: str, discount: int, uses: int, created_by: int, expires_at: datetime = None, is_unlimited: bool = False, bonus_days: int = 0, is_sticky: bool = False):
await self.execute(
"INSERT INTO promo_codes (code, discount, uses_left, created_by, expires_at, is_unlimited, bonus_days, is_sticky) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
code, discount, uses, created_by, expires_at, is_unlimited, bonus_days, is_sticky
)
async def set_user_discount(self, user_id: int, discount: int):
await self.execute("UPDATE users SET personal_discount = $1 WHERE user_id = $2", discount, user_id)
async def get_promo_code(self, code: str):
# Check basic validity
promo = await self.fetchrow("SELECT * FROM promo_codes WHERE code = $1 AND uses_left > 0", code)
if not promo:
return None
# Check expiration logic manually or via SQL if dialect allows. Let's do manual for safety across SQLite/PG
expires_at = promo['expires_at']
if expires_at:
if isinstance(expires_at, str):
try:
expires_at = datetime.strptime(expires_at, '%Y-%m-%d %H:%M:%S')
except:
try:
expires_at = datetime.strptime(expires_at, '%Y-%m-%d %H:%M:%S.%f')
except:
pass
if isinstance(expires_at, datetime) and expires_at < datetime.now():
return None
return promo
async def get_active_promos(self):
# Return only potentially active promos
promos = await self.fetch("SELECT * FROM promo_codes WHERE uses_left > 0")
active = []
now = datetime.now()
for p in promos:
exp = p['expires_at']
if isinstance(exp, str):
try:
exp = datetime.strptime(exp, '%Y-%m-%d %H:%M:%S')
except:
pass
if not exp or (isinstance(exp, datetime) and exp > now):
active.append(p)
return active
async def decrement_promo_usage(self, code: str):
await self.execute("UPDATE promo_codes SET uses_left = uses_left - 1 WHERE code = $1", code)
async def add_payment(self, user_id: int, plan: str, amount: int, promo_code: str = None):
await self.execute(
"INSERT INTO payments (user_id, plan, amount, promo_code) VALUES ($1, $2, $3, $4)",
user_id, plan, amount, promo_code
)
async def get_stats(self):
total_users = await self.fetchval("SELECT COUNT(*) FROM users")
active_revenue = await self.fetchval("SELECT SUM(amount) FROM payments") or 0
if self.is_sqlite:
active_subs = await self.fetchval("SELECT COUNT(*) FROM users WHERE subscription_until > datetime('now')")
else:
active_subs = await self.fetchval("SELECT COUNT(*) FROM users WHERE subscription_until > NOW()")
return {
"total": total_users,
"revenue": active_revenue,
"active": active_subs
}
async def get_users_for_broadcast(self):
return await self.fetch("SELECT user_id FROM users")
db = Database()

7
handlers/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
from . import user, admin, payment
routers = [
user.router,
admin.router,
payment.router
]

735
handlers/admin.py Normal file
View File

@@ -0,0 +1,735 @@
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.fsm.context import FSMContext
from datetime import datetime, timedelta
import random
import string
import logging
from config import CONFIG
from database import db
from marzban import marzban
from states import BroadcastStates, PromoStates, AdminUserStates
from keyboards import admin_keyboard
# Add new states for user adding
from aiogram.fsm.state import State, StatesGroup
class AddUserStates(StatesGroup):
waiting_for_id = State()
router = Router()
logger = logging.getLogger(__name__)
@router.callback_query(F.data == "admin_panel")
async def admin_panel(callback: CallbackQuery):
if callback.from_user.id not in CONFIG["ADMIN_IDS"]:
await callback.answer("❌ Доступ запрещен", show_alert=True)
return
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):
if callback.from_user.id not in CONFIG["ADMIN_IDS"]: return
stats = await db.get_stats()
text = (
"📊 Статистика бота:\n\n"
f"👥 Всего пользователей: {stats['total']}\n"
f"✅ Активных подписок: {stats['active']}\n"
f"💰 Общая выручка: {stats['revenue']}\n"
)
await callback.message.edit_text(text, reply_markup=admin_keyboard())
@router.callback_query(F.data == "admin_server_stats")
async def admin_server_stats(callback: CallbackQuery):
if callback.from_user.id not in CONFIG["ADMIN_IDS"]: return
try:
sys = await marzban.get_system_stats()
usrs = await marzban.get_users_stats()
text = (
"🖥️ Статистика сервера:\n\n"
f"📊 CPU: {sys.get('cpu_usage', 'N/A')}%\n"
f"💾 RAM: {sys.get('mem_used', 0)/(1024**3):.2f}/{sys.get('mem_total', 0)/(1024**3):.2f} GB\n"
f"👥 Активных юзеров: {usrs.get('active_users', 0)}\n"
f"📦 Всего трафика: {usrs.get('total_usage', 0)/(1024**3):.2f} GB"
)
except Exception as e:
logger.error(f"Error: {e}")
text = "⚠️ Ошибка Marzban API"
await callback.message.edit_text(text, reply_markup=admin_keyboard())
# --- Add Invite / Direct User Add ---
@router.callback_query(F.data == "admin_add_invite")
async def admin_add_invite_menu(callback: CallbackQuery):
if callback.from_user.id not in CONFIG["ADMIN_IDS"]: return
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔢 Сгенерировать код", callback_data="admin_gen_code")],
[InlineKeyboardButton(text="👤 Добавить по ID/Username", callback_data="admin_add_user_direct")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
])
await callback.message.edit_text("Выберите способ добавления:", reply_markup=kb)
@router.callback_query(F.data == "admin_gen_code")
async def admin_gen_code(callback: CallbackQuery):
code = await db.create_invite_code(callback.from_user.id)
# Получаем юзернейм бота
bot_info = await callback.bot.get_me()
bot_username = bot_info.username
# Используем HTML, так как Markdown с подчеркиваниями часто ломается
await callback.message.edit_text(
f"✅ Новый инвайт-код:\n<code>{code}</code>\n\n"
f"🔗 Ссылка для приглашения:\n"
f"https://t.me/{bot_username}?start={code}",
parse_mode="HTML",
reply_markup=admin_keyboard()
)
@router.callback_query(F.data == "admin_add_user_direct")
async def admin_add_user_direct(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"Введите Telegram ID (число) или Username (без @) пользователя:",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Отмена", callback_data="admin_panel")]])
)
await state.set_state(AddUserStates.waiting_for_id)
@router.message(AddUserStates.waiting_for_id)
async def process_direct_add(message: Message, state: FSMContext):
input_data = message.text.strip()
# Try to determine if it is ID or Username.
# NOTE: We can only add by ID correctly IF the user has started the bot before (to get chat info),
# OR if we just blindly trust the ID for the DB.
# But usually, if adding by Username, we can't get ID easily without bot API interaction (get_chat).
user_id = None
username = None
if input_data.isdigit():
user_id = int(input_data)
username = f"user_{user_id}"
else:
# Username logic is tricky because we need the numeric ID for the 'users' table primary key.
# Without it, we can't insert into DB correctly if schema requires BIGINT KEY.
# We'll try to resolve via bot API, but it often fails if bot never saw user.
try:
# Try to resolve chat? Bot API doesn't allow get_chat for users who didn't block bot, but...
# Let's hope for the best or assume it's impossible without ID.
await message.answer("⚠️ Добавление по юзернейму ненадежно без ID. Лучше используйте ID.")
return
except Exception:
pass
if user_id:
existing = await db.get_user(user_id)
if existing:
await message.answer("❌ Пользователь уже есть в базе.")
else:
marzban_username = f"user_{user_id}"
await db.create_user(user_id, username, marzban_username, message.from_user.id)
await message.answer(f"✅ Пользователь {user_id} добавлен в базу!")
await state.clear()
await message.answer("Главное меню", reply_markup=main_keyboard(True))
# --- Promo Management ---
@router.callback_query(F.data == "admin_promos")
async def admin_promos(callback: CallbackQuery):
if callback.from_user.id not in CONFIG["ADMIN_IDS"]: return
promos = await db.get_active_promos() # Only valid ones
text = "🏷 <b>Активные промокоды:</b>\n\n"
if not promos:
text = "Нет активных промокодов."
for p in promos:
# Обработка даты (SQLite возвращает строку)
exp_val = p['expires_at']
exp_dt = None
if exp_val:
if isinstance(exp_val, str):
try:
exp_dt = datetime.strptime(exp_val, '%Y-%m-%d %H:%M:%S')
except ValueError:
try:
exp_dt = datetime.strptime(exp_val, '%Y-%m-%d %H:%M:%S.%f')
except:
pass
elif isinstance(exp_val, datetime):
exp_dt = exp_val
exp_str = exp_dt.strftime('%d.%m.%Y') if exp_dt else ""
# Получаем значения по ключам (не через get)
is_unl = p['is_unlimited']
type_str = " (VIP ∞)" if is_unl else f" (-{p['discount']}%)"
text += (
f"🔹 <code>{p['code']}</code>{type_str}\n"
f" Осталось: {p['uses_left']} | До: {exp_str}\n"
)
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=" Создать промокод", callback_data="admin_create_promo")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
])
await callback.message.edit_text(text, reply_markup=kb, parse_mode="HTML")
@router.callback_query(F.data == "admin_create_promo")
async def start_create_promo(callback: CallbackQuery, state: FSMContext):
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✍️ Свой вариант", callback_data="promo_name_custom")],
[InlineKeyboardButton(text="🎲 Сгенерировать", callback_data="promo_name_generate")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
])
await callback.message.edit_text("Как задать название промокода?", reply_markup=kb)
@router.callback_query(F.data == "promo_name_custom")
async def promo_name_custom(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text("Введите НАЗВАНИЕ промокода (например, NEWYEAR):")
await state.set_state(PromoStates.waiting_for_name)
@router.callback_query(F.data == "promo_name_generate")
async def promo_name_generate(callback: CallbackQuery, state: FSMContext):
code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
await proceed_after_name(callback, state, code)
async def proceed_after_name(message_or_call, state: FSMContext, code: str):
await state.update_data(code=code)
text = f"Название: <code>{code}</code>\n\nВведите размер скидки в % (от 0 до 100):\nМожно ввести 0, если это только бонус-код."
# Кнопка для быстрого VIP (чтобы не проходить все шаги если нужен просто VIP)
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="♾ Сделать VIP (Бесконечный)", callback_data="promo_make_vip")]
])
if isinstance(message_or_call, CallbackQuery):
await message_or_call.message.edit_text(text, reply_markup=kb, parse_mode="HTML")
else:
await message_or_call.answer(text, reply_markup=kb, parse_mode="HTML")
await state.set_state(PromoStates.waiting_for_discount)
@router.message(PromoStates.waiting_for_name)
async def promo_name_entered(message: Message, state: FSMContext):
await proceed_after_name(message, state, message.text.upper().strip())
@router.callback_query(F.data == "promo_make_vip")
async def promo_make_vip(callback: CallbackQuery, state: FSMContext):
# VIP shortcut
await state.update_data(discount=100, is_unlimited=True, bonus_days=0)
await callback.message.edit_text("Введите количество использований (число):")
await state.set_state(PromoStates.waiting_for_uses)
@router.message(PromoStates.waiting_for_discount)
async def promo_discount_step(message: Message, state: FSMContext):
try:
val = int(message.text)
if not 0 <= val <= 100: raise ValueError
await state.update_data(discount=val, is_unlimited=False)
if val > 0:
# Если есть скидка, спрашиваем, закрепить ли её
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Да, закрепить цену навсегда", callback_data="promo_sticky_yes")],
[InlineKeyboardButton(text="❌ Нет, одноразовая", callback_data="promo_sticky_no")]
])
await message.answer("Закрепить эту скидку за пользователем НАВСЕГДА? (Цена останется такой же при продлении)", reply_markup=kb)
else:
# Скидки нет, пропускаем шаг
await state.update_data(is_sticky=False)
await message.answer("Введите количество БОНУСНЫХ ДНЕЙ (0 если нет):")
await state.set_state(PromoStates.waiting_for_bonus)
except:
await message.answer("Введите число от 0 до 100!")
@router.callback_query(F.data.startswith("promo_sticky_"))
async def promo_sticky_callback(callback: CallbackQuery, state: FSMContext):
is_sticky = (callback.data == "promo_sticky_yes")
await state.update_data(is_sticky=is_sticky)
await callback.message.edit_text("Введите количество БОНУСНЫХ ДНЕЙ (0 если нет):")
await state.set_state(PromoStates.waiting_for_bonus)
@router.message(PromoStates.waiting_for_bonus)
async def promo_bonus_step(message: Message, state: FSMContext):
try:
val = int(message.text)
if val < 0: raise ValueError
await state.update_data(bonus_days=val)
await message.answer("Введите количество использований (число):")
await state.set_state(PromoStates.waiting_for_uses)
except:
await message.answer("Введите положительное число или 0!")
# Quick fix for the missing state step:
# I will use `PromoStates.waiting_for_name` again but with flagging? No, bad practice.
# Let's add the state to `states.py` in my mind, but since I can't edit that file instantly without tool,
# I will just define a handler that catches "waiting_for_uses" + logic...
# No, let's just make the user input days NOW in `promo_uses`?
# Ah, I need to read the previous input first.
# See below implementation.
# ... Redoing `promo_uses` to chaining correctly ...
@router.message(PromoStates.waiting_for_uses)
async def promo_uses_step(message: Message, state: FSMContext):
try:
val = int(message.text)
if val < 1: raise ValueError
await state.update_data(uses=val)
# Кнопки с пресетами
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="♾ Навсегда", callback_data="days_0")],
[InlineKeyboardButton(text="📅 30 дней", callback_data="days_30"),
InlineKeyboardButton(text="📅 180 дней", callback_data="days_180")],
[InlineKeyboardButton(text="📅 1 год", callback_data="days_365")],
[InlineKeyboardButton(text="✍️ Свой вариант", callback_data="days_custom")]
])
await message.answer(
"Сколько дней будет действовать промокод?\n"
"Выберите вариант или введите число вручную:",
reply_markup=kb
)
await state.set_state(PromoStates.waiting_for_days)
except:
await message.answer("Введите корректное число использований!")
# Общая функция создания
async def create_promo_final(message_or_call, state: FSMContext, days: int):
data = await state.get_data()
expires_at = None
if days > 0:
expires_at = datetime.now() + timedelta(days=days)
try:
await db.create_promo_code(
data['code'],
data['discount'],
data['uses'],
message_or_call.from_user.id,
expires_at,
data.get('is_unlimited', False),
data.get('bonus_days', 0),
data.get('is_sticky', False)
)
except Exception as e:
logger.error(f"Error creating promo: {e}")
error_text = "❌ Ошибка: Такой промокод уже существует или произошел сбой БД."
if isinstance(message_or_call, CallbackQuery):
await message_or_call.message.edit_text(error_text, reply_markup=admin_keyboard())
else:
await message_or_call.answer(error_text, reply_markup=admin_keyboard())
await state.clear()
return
bonus = data.get('bonus_days', 0)
is_sticky = data.get('is_sticky', False)
if data.get('is_unlimited'):
type_text = "♾ VIP"
else:
parts = []
if data['discount'] > 0:
fixed = " (FIXED)" if is_sticky else ""
parts.append(f"-{data['discount']}%{fixed}")
if bonus > 0:
parts.append(f"+{bonus}d")
type_text = " ".join(parts) if parts else "Standard"
exp_text = expires_at.strftime('%d.%m.%Y') if expires_at else "Бессрочно"
confirm_text = (
f"✅ <b>Промокод создан!</b>\n\n"
f"Code: <code>{data['code']}</code>\n"
f"Type: {type_text}\n"
f"Uses: {data['uses']}\n"
f"Expires: {exp_text}"
)
if isinstance(message_or_call, CallbackQuery):
await message_or_call.message.edit_text(confirm_text, reply_markup=admin_keyboard(), parse_mode="HTML")
else:
await message_or_call.answer(confirm_text, reply_markup=admin_keyboard(), parse_mode="HTML")
await state.clear()
@router.callback_query(PromoStates.waiting_for_days, F.data == "days_custom")
async def promo_days_custom(callback: CallbackQuery):
await callback.message.edit_text("✍️ Введите срок действия промокода в днях (целое число):")
await callback.answer()
@router.callback_query(PromoStates.waiting_for_days, F.data.regexp(r"^days_\d+$"))
async def promo_days_callback(callback: CallbackQuery, state: FSMContext):
days = int(callback.data.split("_")[1])
await create_promo_final(callback, state, days)
@router.message(PromoStates.waiting_for_days)
async def promo_days_manual(message: Message, state: FSMContext):
try:
days = int(message.text)
if days < 0: raise ValueError
await create_promo_final(message, state, days)
except:
await message.answer("Введите число (0 или больше)!")
# --- Broadcast ---
@router.callback_query(F.data == "admin_broadcast")
async def admin_broadcast_start(callback: CallbackQuery, state: FSMContext):
if callback.from_user.id not in CONFIG["ADMIN_IDS"]: return
await callback.message.edit_text("Сообщение для рассылки:", reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Отмена", callback_data="admin_panel")]]))
await state.set_state(BroadcastStates.waiting_for_message)
@router.message(BroadcastStates.waiting_for_message)
async def broadcast_go(message: Message, state: FSMContext):
users = await db.get_users_for_broadcast()
count = 0
msg = await message.answer("Рассылаю...")
for u in users:
try:
await message.send_copy(u['user_id'])
count+=1
except: pass
await msg.edit_text(f"✅ Отправлено: {count}", reply_markup=admin_keyboard())
await state.clear()
# --- User Management ---
@router.callback_query(F.data == "admin_users_list")
async def admin_users_list(callback: CallbackQuery):
if callback.from_user.id not in CONFIG["ADMIN_IDS"]: return
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔍 Найти пользователя", callback_data="admin_search_user")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
])
await callback.message.edit_text("👥 Управление пользователями", reply_markup=kb)
@router.callback_query(F.data == "admin_search_user")
async def admin_search_user(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"Введите Telegram ID или Username пользователя:",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Отмена", callback_data="admin_panel")]])
)
await state.set_state(AdminUserStates.waiting_for_search)
@router.message(AdminUserStates.waiting_for_search)
async def process_user_search(message: Message, state: FSMContext):
query = message.text.strip()
users = await db.search_users(query)
if not users:
await message.answer(
"❌ Пользователи не найдены.\nПопробуйте другой запрос:",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Отмена", callback_data="admin_panel")]])
)
return
if len(users) == 1:
await show_user_panel(message, users[0]["user_id"])
await state.clear()
else:
# Show list
kb_rows = []
for u in users:
display = u['username'] if u['username'] else f"ID: {u['user_id']}"
kb_rows.append([InlineKeyboardButton(text=f"{display} | {u['user_id']}", callback_data=f"adm_sel_{u['user_id']}")])
kb_rows.append([InlineKeyboardButton(text="◀️ Отмена", callback_data="admin_users_list")])
await message.answer(f"🔍 Найдено {len(users)} пользователей:\nВыберите пользователя:", reply_markup=InlineKeyboardMarkup(inline_keyboard=kb_rows))
await state.clear()
@router.callback_query(F.data.startswith("adm_sel_"))
async def admin_user_select(callback: CallbackQuery):
user_id = int(callback.data.split("_")[2])
await show_user_panel(callback, user_id)
async def show_user_panel(message_or_call, user_id):
user = await db.get_user(user_id)
if not user:
if isinstance(message_or_call, CallbackQuery): await message_or_call.answer("User not found")
else: await message_or_call.answer("User not found")
return
# Get marzban info
marz_info = {}
try:
marz_info = await marzban.get_user(user['marzban_username'])
except:
pass
status = marz_info.get('status', 'Unknown')
used_traffic = marz_info.get('used_traffic', 0)
data_limit = marz_info.get('data_limit', 0)
traffic_used_gb = used_traffic / (1024**3) if used_traffic else 0
traffic_limit_gb = data_limit / (1024**3) if data_limit else 0
sub_until = user['subscription_until']
if sub_until and isinstance(sub_until, str):
try:
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S')
except:
try:
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S.%f')
except: pass
exp_str = sub_until.strftime('%d.%m.%Y %H:%M') if sub_until and isinstance(sub_until, datetime) else "Нет подписки"
username_display = user['username'] if user['username'] else str(user['user_id'])
status_icon = "🟢" if status == 'active' else "🔴"
text = (
f"👤 <b>Пользователь:</b> <a href='tg://user?id={user['user_id']}'>{username_display}</a>\n"
f"🆔 ID: <code>{user['user_id']}</code>\n"
f"🔋 Статус Marzban: {status_icon} {status}\n"
f"📅 Подписка до: {exp_str}\n"
f"📊 Трафик: {traffic_used_gb:.2f} / {traffic_limit_gb:.2f} GB\n"
)
# Dynamic buttons
status_btn = InlineKeyboardButton(text="⛔️ Заблокировать", callback_data=f"adm_usr_ban_{user_id}")
if status == 'disabled':
status_btn = InlineKeyboardButton(text="✅ Разблокировать", callback_data=f"adm_usr_unban_{user_id}")
rows = [
[InlineKeyboardButton(text=" Продлить", callback_data=f"adm_usr_add_{user_id}"),
InlineKeyboardButton(text="✏️ Лимит", callback_data=f"adm_usr_gb_{user_id}")],
[status_btn,
InlineKeyboardButton(text="🔄 Сброс трафика", callback_data=f"adm_usr_reset_{user_id}")]
]
# Только если есть активная дата подписки
if sub_until and isinstance(sub_until, datetime):
rows.append([InlineKeyboardButton(text="❌ Удалить подписку", callback_data=f"adm_usr_delsub_{user_id}")])
rows.append([InlineKeyboardButton(text="✉️ Сообщение", callback_data=f"adm_usr_msg_{user_id}")])
rows.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_users_list")])
kb = InlineKeyboardMarkup(inline_keyboard=rows)
if isinstance(message_or_call, CallbackQuery):
# We try to edit if possible, but if message content is same it errors.
# However, status or buttons likely changed so it is fine.
try:
await message_or_call.message.edit_text(text, reply_markup=kb, parse_mode="HTML")
except:
await message_or_call.message.delete()
await message_or_call.message.answer(text, reply_markup=kb, parse_mode="HTML")
else:
await message_or_call.answer(text, reply_markup=kb, parse_mode="HTML")
# Add Time
@router.callback_query(F.data.startswith("adm_usr_add_"))
async def adm_usr_add_start(callback: CallbackQuery, state: FSMContext):
user_id = int(callback.data.split("_")[3])
await state.update_data(target_user_id=user_id)
await callback.message.edit_text(
"Введите количество дней для добавления (целое число).\n"
"Или отрицательное число, чтобы уменьшить срок.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Отмена", callback_data=f"admin_users_list")]])
)
await state.set_state(AdminUserStates.waiting_for_days)
@router.message(AdminUserStates.waiting_for_days)
async def adm_usr_add_process(message: Message, state: FSMContext):
try:
days = int(message.text)
data = await state.get_data()
user_id = data['target_user_id']
user = await db.get_user(user_id)
limit = user['data_limit'] if user['data_limit'] else 0
await db.update_subscription(user_id, days, limit)
# Update Marzban
updated_user = await db.get_user(user_id)
sub_until = updated_user['subscription_until']
if isinstance(sub_until, str):
try:
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S')
except:
try:
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S.%f')
except: pass
if sub_until:
delta = sub_until - datetime.now()
days_left = delta.days + 1 if delta.days >= 0 else 0
else:
days_left = 0
limit_gb = limit / (1024**3) if limit else 0
try:
await marzban.modify_user(updated_user['marzban_username'], limit_gb, days_left)
await message.answer(f"✅ Добавлено {days} дней пользователю {user_id}")
except Exception as e:
await message.answer(f"⚠️ БД обновлена, но Marzban вернул ошибку: {e}")
await show_user_panel(message, user_id)
await state.clear()
except ValueError:
await message.answer("Ошибка. Введите целое число.")
# Reset Traffic
@router.callback_query(F.data.startswith("adm_usr_reset_"))
async def adm_usr_reset(callback: CallbackQuery):
user_id = int(callback.data.split("_")[3])
user = await db.get_user(user_id)
if user:
try:
await marzban.reset_user_traffic(user['marzban_username'])
await callback.answer("✅ Трафик сброшен", show_alert=True)
except Exception as e:
await callback.answer(f"Ошибка: {e}", show_alert=True)
await show_user_panel(callback, user_id)
# Send Message
@router.callback_query(F.data.startswith("adm_usr_msg_"))
async def adm_usr_msg_start(callback: CallbackQuery, state: FSMContext):
user_id = int(callback.data.split("_")[3])
await state.update_data(target_user_id=user_id)
await callback.message.edit_text(
"Введите сообщение для отправки пользователю:",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Отмена", callback_data="admin_users_list")]])
)
await state.set_state(AdminUserStates.waiting_for_message)
@router.message(AdminUserStates.waiting_for_message)
async def adm_usr_msg_send(message: Message, state: FSMContext):
data = await state.get_data()
user_id = data['target_user_id']
try:
await message.send_copy(user_id)
await message.answer(f"✅ Сообщение отправлено пользователю {user_id}")
except Exception as e:
await message.answer(f"❌ Ошибка отправки: {e}")
await show_user_panel(message, user_id)
await state.clear()
# Ban/Unban
@router.callback_query(F.data.regexp(r"^adm_usr_(ban|unban)_\d+$"))
async def adm_usr_toggle_status(callback: CallbackQuery):
action = callback.data.split("_")[2]
user_id = int(callback.data.split("_")[3])
new_status = "disabled" if action == "ban" else "active"
user = await db.get_user(user_id)
if user:
try:
marz_user = await marzban.get_user(user['marzban_username'])
current_limit = marz_user.get('data_limit')
current_limit_gb = (current_limit / (1024**3)) if current_limit else 0
expire_ts = marz_user.get('expire')
await marzban.modify_user(user['marzban_username'], current_limit_gb, status=new_status, expire_timestamp=expire_ts)
await callback.answer(f"Статус изменен на {new_status}", show_alert=True)
except Exception as e:
await callback.answer(f"Ошибка: {e}", show_alert=True)
await show_user_panel(callback, user_id)
# Limit Change
@router.callback_query(F.data.startswith("adm_usr_gb_"))
async def adm_usr_limit_start(callback: CallbackQuery, state: FSMContext):
user_id = int(callback.data.split("_")[3])
await state.update_data(target_user_id=user_id)
await callback.message.edit_text(
"Введите новый лимит трафика в GB (число):\n0 = Безлимит (если поддерживается)",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Отмена", callback_data=f"adm_sel_{user_id}")]])
)
await state.set_state(AdminUserStates.waiting_for_limit)
@router.message(AdminUserStates.waiting_for_limit)
async def adm_usr_limit_process(message: Message, state: FSMContext):
try:
limit_gb = float(message.text)
if limit_gb < 0: raise ValueError
data = await state.get_data()
user_id = data['target_user_id']
user = await db.get_user(user_id)
marz_user = await marzban.get_user(user['marzban_username'])
expire_ts = marz_user.get('expire')
current_status = marz_user.get('status', 'active')
await marzban.modify_user(user['marzban_username'], limit_gb, status=current_status, expire_timestamp=expire_ts)
limit_bytes = int(limit_gb * 1024 * 1024 * 1024)
await db.execute("UPDATE users SET data_limit = $1 WHERE user_id = $2", limit_bytes, user_id)
await message.answer(f"✅ Лимит изменен на {limit_gb} GB")
await show_user_panel(message, user_id)
await state.clear()
except ValueError:
await message.answer("Введите корректное число!")
# Delete Subscription
@router.callback_query(F.data.startswith("adm_usr_delsub_"))
async def adm_usr_delsub_ask(callback: CallbackQuery):
user_id = int(callback.data.split("_")[3])
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Да, удалить", callback_data=f"adm_usr_confirm_delsub_{user_id}")],
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"adm_sel_{user_id}")]
])
await callback.message.edit_text(
f"⚠️ <b>Вы уверены, что хотите удалить подписку у пользователя {user_id}?</b>\n"
"Пользователь потеряет доступ к VPN (срок действия истечет сейчас).",
reply_markup=kb,
parse_mode="HTML"
)
@router.callback_query(F.data.startswith("adm_usr_confirm_delsub_"))
async def adm_usr_delsub_confirm(callback: CallbackQuery):
user_id = int(callback.data.split("_")[4])
user = await db.get_user(user_id)
if not user:
await callback.answer("Пользователь не найден")
return
# Update DB
await db.remove_subscription(user_id)
# Update Marzban
try:
# Expire immediately
expire_ts = int(datetime.now().timestamp())
marz_user = await marzban.get_user(user['marzban_username'])
current_limit = marz_user.get('data_limit')
current_limit_gb = (current_limit / (1024**3)) if current_limit else 0
current_status = marz_user.get('status', 'active')
await marzban.modify_user(
user['marzban_username'],
current_limit_gb,
status=current_status,
expire_timestamp=expire_ts
)
await callback.answer("✅ Подписка удалена", show_alert=True)
except Exception as e:
await callback.answer(f"Ошибка Marzban: {e}", show_alert=True)
await show_user_panel(callback, user_id)

217
handlers/payment.py Normal file
View File

@@ -0,0 +1,217 @@
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.fsm.context import FSMContext
from datetime import datetime
import logging
from config import CONFIG, PLANS
from database import db
from marzban import marzban
from keyboards import main_keyboard
router = Router()
logger = logging.getLogger(__name__)
async def grant_subscription(user_id: int, plan_id: str, promo_code: str, amount: int, bonus_days: int = 0):
total_days = 0
data_limit = 0
plan_name = "VIP Sub"
marzban_days = 0
if plan_id:
plan = PLANS[plan_id]
total_days = plan['days'] + bonus_days
marzban_days = total_days
data_limit = plan['data_limit']
plan_name = plan['name']
else:
# VIP case without plan
total_days = 365 * 99 # For DB (99 years)
marzban_days = 0 # For Marzban (Unlimited/None)
data_limit = 0 # Unlimited
plan_name = "VIP"
user = await db.get_user(user_id)
tg_username = user['username']
note = f"@{tg_username}" if tg_username else ""
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:
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S.%f')
except: pass
# Marzban
try:
marzban_username = user['marzban_username']
resp = None
# Если есть подписка и она активна (и не бесконечна, хотя тут не важно)
is_sub_active = sub_until and sub_until > datetime.now()
if is_sub_active:
logger.info(f"Attempting to modify existing user: {marzban_username}")
resp = await marzban.modify_user(
marzban_username,
data_limit,
marzban_days,
note
)
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,
data_limit,
marzban_days,
note
)
else:
logger.info(f"Creating/Reactivating user: {marzban_username}")
resp = await marzban.create_user(
marzban_username,
data_limit,
marzban_days,
note
)
except Exception as e:
logger.error(f"Marzban error in grant_subscription: {e}")
# DB
await db.update_subscription(user_id, total_days, data_limit)
await db.add_payment(
user_id,
plan_id or "vip", # Store 'vip' as plan name/id
amount,
promo_code
)
if promo_code:
await db.decrement_promo_usage(promo_code)
# Return dummy plan dict for display
return {'name': plan_name, 'days': total_days, 'data_limit': data_limit}, total_days
@router.callback_query(F.data == "pay_now")
async def process_payment(callback: CallbackQuery, state: FSMContext):
data = await state.get_data()
plan_id = data.get('selected_plan')
is_vip = data.get('is_unlimited_promo')
if not plan_id and not is_vip:
await callback.answer("❌ Сессия истекла. Пожалуйста, выберите тариф снова.", show_alert=True)
return
# Если VIP без плана, ставим дефолтные значения
plan_name = "VIP"
plan_days = 3650
plan_limit = 0
base_price = 0
if plan_id:
plan = PLANS[plan_id]
plan_name = plan['name']
plan_days = plan['days']
plan_limit = plan['data_limit']
base_price = plan['price']
final_price = int(data.get('final_price', base_price))
promo_code = data.get('promo_code')
bonus_days = data.get('bonus_days', 0)
if final_price <= 0:
sticky_msg = ""
if promo_code:
p_data = await db.get_promo_code(promo_code)
# Use explicit key access with fallback logic if needed, but keys exist
if p_data and p_data['is_sticky']:
user = await db.get_user(callback.from_user.id)
u_disc = user['personal_discount'] if user and user['personal_discount'] else 0
if p_data['discount'] > u_disc:
await db.set_user_discount(callback.from_user.id, p_data['discount'])
sticky_msg = "\n🔐 Скидка закреплена навсегда!"
plan, date_days = await grant_subscription(callback.from_user.id, plan_id, promo_code, 0, bonus_days)
await callback.message.edit_text(
f"✅ Подписка активирована бесплатно!\n\n"
f"План: {plan['name']}\n"
f"Срок: {date_days} дней\n"
f"Трафик: {plan['data_limit'] if plan['data_limit'] > 0 else ''} ГБ\n"
f"{sticky_msg}\n"
f"Настройте подключение в меню: 📊 Моя подписка",
reply_markup=main_keyboard(callback.from_user.id in CONFIG["ADMIN_IDS"])
)
await state.clear()
else:
# Создаем инвойс для Telegram Stars
await callback.message.answer_invoice(
title=f"Подписка VPN - {plan['name']}",
description=f"Трафик: {plan['data_limit']} ГБ на {plan['days']} дней",
payload=f"{plan_id}:{data.get('promo_code', '')}",
currency="XTR", # Telegram Stars
prices=[LabeledPrice(label=plan['name'], amount=final_price)],
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Оплатить", pay=True)]
])
)
@router.pre_checkout_query()
async def checkout_process(pre_checkout_query):
await pre_checkout_query.answer(ok=True)
@router.message(F.successful_payment)
async def successful_payment(message: Message):
payment = message.successful_payment
plan_id, promo_code = payment.invoice_payload.split(":")
# We can reuse grant_subscription helper
promo_code = promo_code if promo_code else None
bonus_days = 0
sticky_text = ""
if promo_code:
# Fetch actual promo details
promo_data = await db.get_promo_code(promo_code)
if promo_data:
bonus_days = promo_data['bonus_days']
# STICKY LOGIC
# Access by key (sqlite3.Row has no .get method)
is_sticky = False
try:
is_sticky = promo_data['is_sticky']
except IndexError:
pass # Column missing?
if is_sticky:
user = await db.get_user(message.from_user.id)
current_discount = user['personal_discount'] if user and user['personal_discount'] else 0
new_discount = promo_data['discount']
if new_discount > current_discount:
await db.set_user_discount(message.from_user.id, new_discount)
sticky_text = f"\n🔐 <b>Скидка {new_discount}% закреплена за вами НАВСЕГДА!</b>"
plan, date_days = await grant_subscription(
message.from_user.id,
plan_id,
promo_code,
payment.total_amount,
bonus_days
)
await message.answer(
f"✅ Оплата успешна!\n\n"
f"Ваша подписка активирована на {date_days} дней.\n"
f"Трафик: {plan['data_limit']} ГБ\n"
f"{sticky_text}\n"
f"Получите конфигурацию через меню: 📊 Моя подписка",
reply_markup=main_keyboard(message.from_user.id in CONFIG["ADMIN_IDS"]),
parse_mode="HTML"
)

477
handlers/user.py Normal file
View File

@@ -0,0 +1,477 @@
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton, BufferedInputFile
from aiogram.fsm.context import FSMContext
from aiogram.filters import Command, StateFilter, CommandStart, CommandObject
from datetime import datetime
import qrcode
from qrcode import QRCode
import io
import logging
from config import CONFIG, PLANS
from database import db
from marzban import marzban
from states import InviteStates, PromoStates
from keyboards import main_keyboard, plans_keyboard
router = Router()
logger = logging.getLogger(__name__)
# Helper to check if sub is active
def is_active(user):
if not user:
return False
sub_until = user['subscription_until']
if not sub_until:
return False
if isinstance(sub_until, str):
try:
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S')
except:
try:
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S.%f')
except:
return False # Parse error
return sub_until > datetime.now()
@router.message(CommandStart(deep_link=True))
async def cmd_start_deep_link(message: Message, command: CommandObject, state: FSMContext):
# Если запуск по ссылке (инвайт код)
code = command.args
invite = await db.check_invite_code(code)
if invite:
user_id = message.from_user.id
# Проверяем, не зарегистрирован ли уже
existing_user = await db.get_user(user_id)
if existing_user:
await message.answer("Вы уже зарегистрированы! Инвайт-код не нужен.")
await show_main_menu(message, user_id)
return
username = message.from_user.username or f"user_{user_id}"
marzban_username = message.from_user.username.lower() if message.from_user.username else f"user_{user_id}"
await db.create_user(user_id, username, marzban_username, invite['created_by'])
await db.use_invite_code(code, user_id)
await message.answer("✅ Инвайт-код принят! Добро пожаловать.")
await show_main_menu(message, user_id)
else:
await message.answer("❌ Неверный или использованный инвайт-код.")
# Fallback to normal start logic check
await cmd_start(message, state)
@router.message(Command("start"))
async def cmd_start(message: Message, state: FSMContext):
user_id = message.from_user.id
user = await db.get_user(user_id)
# Авторегистрация админа
if user_id in CONFIG["ADMIN_IDS"] and not user:
username = message.from_user.username or f"user_{user_id}"
marzban_username = message.from_user.username.lower() if message.from_user.username else f"user_{user_id}"
await db.create_user(user_id, username, marzban_username)
user = await db.get_user(user_id)
if user:
await show_main_menu(message, user_id)
else:
await message.answer(
"👋 Добро пожаловать!\n\n"
"Для использования бота необходим инвайт-код.\n"
"Если у вас есть ссылка-приглашение, перейдите по ней.\n"
"Или введите ваш инвайт-код вручную:"
)
await state.set_state(InviteStates.waiting_for_code)
@router.message(Command("myid"), StateFilter("*"))
async def cmd_myid(message: Message):
username = f"@{message.from_user.username}" if message.from_user.username else "No username"
await message.answer(
f"👤 Ваш профиль:\n"
f"ID: `{message.from_user.id}`\n"
f"User: {username}",
parse_mode="Markdown"
)
async def show_main_menu(message: Message, user_id: int):
user = await db.get_user(user_id)
is_admin = user_id in CONFIG["ADMIN_IDS"]
active = is_active(user)
await message.answer(
f"Привет, {message.from_user.first_name}! 👋\n\n"
"Главное меню:",
reply_markup=main_keyboard(is_admin, active)
)
@router.message(InviteStates.waiting_for_code)
async def process_invite_code(message: Message, state: FSMContext):
code = message.text.strip().upper()
invite = await db.check_invite_code(code)
if invite:
user_id = message.from_user.id
username = message.from_user.username or f"user_{user_id}"
marzban_username = message.from_user.username.lower() if message.from_user.username else f"user_{user_id}"
await db.create_user(user_id, username, marzban_username, invite['created_by'])
await db.use_invite_code(code, user_id)
await message.answer("✅ Инвайт-код принят! Добро пожаловать.")
await show_main_menu(message, user_id)
await state.clear()
else:
await message.answer("❌ Неверный или использованный инвайт-код. Попробуйте еще раз:")
@router.callback_query(F.data == "my_subscription")
async def show_subscription(callback: CallbackQuery):
user = await db.get_user(callback.from_user.id)
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
sub_until = user['subscription_until']
if not is_active(user):
await callback.answer("❌ Подписка не активна. Купите подписку в главном меню.", show_alert=True)
return
# Handle datetime conversion
if isinstance(sub_until, str):
try:
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S')
except ValueError:
try:
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S.%f')
except:
pass
try:
marzban_user = await marzban.get_user(user['marzban_username'])
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...")
note = f"@{user['username']}" if user.get('username') else ""
# Restore with 30 days default or fetch from DB? Using 30 as per previous code
await marzban.create_user(user['marzban_username'], user['data_limit'] or 50, 30, note)
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 = CONFIG.get('BASE_URL') or CONFIG['MARZBAN_URL']
sub_url = f"{base.rstrip('/')}{sub_url}"
# Check if unlimited (far future date)
if sub_until.year > 2090:
date_str = "♾ Бессрочно"
else:
date_str = sub_until.strftime('%d.%m.%Y %H:%M')
data_limit_gb = user['data_limit']
if data_limit_gb > 10000: # Assuming huge number is unlimited in our DB logic
limit_str = "♾ Безлимит"
else:
limit_str = f"{data_limit_gb} ГБ"
info_text = (
f"📊 Ваша подписка:\n\n"
f"⏰ Действует до: {date_str}\n"
f"📦 Лимит трафика: {limit_str}\n"
f"📊 Использовано: {used_traffic:.2f} ГБ\n\n"
f"🎫 Ссылка на подписку:\n"
f"`{sub_url}`"
)
except Exception as e:
logger.error(f"Error getting user info: {e}")
info_text = "⚠️ Ошибка получения данных. Попробуйте позже."
sub_url = "error"
# Клавиатура с кнопкой продления
sub_kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Продлить подписку", callback_data="buy_subscription")],
[InlineKeyboardButton(text="◀️ В главное меню", callback_data="back_to_main")]
])
try:
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")
await callback.message.delete()
await callback.message.answer_photo(
photo=qr_file,
caption=info_text,
reply_markup=sub_kb,
parse_mode="Markdown"
)
except Exception as e:
logger.error(f"Error sending subscription photo: {e}")
await callback.message.answer(info_text, reply_markup=sub_kb, parse_mode="Markdown")
async def calculate_final_price(base_price: int, discount: int) -> int:
return int(base_price * (100 - discount) / 100)
@router.callback_query(F.data == "buy_subscription")
async def show_plans(callback: CallbackQuery):
user = await db.get_user(callback.from_user.id)
personal_desc = user['personal_discount'] if user and user['personal_discount'] else 0
text = (
"💎 <b>Выберите тарифный план:</b>\n\n"
"Все планы включают:\n"
"• Высокую скорость\n"
"• Поддержку всех устройств"
)
if personal_desc > 0:
text += f"\n\n🔥 Ваша персональная скидка: <b>{personal_desc}%</b>"
kb = plans_keyboard()
if callback.message.photo:
await callback.message.delete()
await callback.message.answer(text, reply_markup=kb, parse_mode="HTML")
else:
await callback.message.edit_text(text, reply_markup=kb, parse_mode="HTML")
@router.callback_query(F.data.startswith("plan_"))
async def process_plan_selection(callback: CallbackQuery, state: FSMContext):
plan_id = callback.data.replace("plan_", "", 1)
plan = PLANS[plan_id]
await state.update_data(selected_plan=plan_id)
data = await state.get_data()
# Check User Personal Discount
user = await db.get_user(callback.from_user.id)
personal_dist = user['personal_discount'] if user and user['personal_discount'] else 0
# Check Promo Discount
promo_dist = data.get('discount', 0)
# Effective Discount = Max of personal or promo
effective_discount = max(personal_dist, promo_dist)
# Calculate Price
final_price = await calculate_final_price(plan['price'], effective_discount)
await state.update_data(final_price=final_price)
msg = (
f"💎 Тариф: <b>{plan['name']}</b>\n"
f"📅 Срок: {plan['days']} дней\n"
f"📦 Трафик: {plan.get('limit_gb', '')} ГБ\n"
f"━━━━━━━━━━━━━━━\n"
)
if effective_discount > 0:
source_text = ""
if personal_dist >= promo_dist and personal_dist > 0:
source_text = "(персональная)"
elif promo_dist > 0:
source_text = "(промокод)"
msg += f"🔥 Скидка {effective_discount}% {source_text}\n"
msg += f"💰 Итого: <s>{plan['price']}</s> <b>{final_price} ⭐</b>"
else:
msg += f"💰 Цена: <b>{plan['price']} ⭐</b>"
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Оплатить", callback_data="pay_now")],
[InlineKeyboardButton(text="🎟 Ввести промокод" if promo_dist == 0 else "❌ Сбросить промокод", callback_data="enter_promo" if promo_dist == 0 else "reset_promo")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="buy_subscription")]
])
if callback.message.photo:
await callback.message.delete()
await callback.message.answer(msg, reply_markup=kb, parse_mode="HTML")
else:
await callback.message.edit_text(msg, reply_markup=kb, parse_mode="HTML")
@router.callback_query(F.data == "enter_promo")
async def ask_promo(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text("Введите промокод:")
await state.set_state(PromoStates.waiting_for_promo)
@router.message(PromoStates.waiting_for_promo)
async def process_promo(message: Message, state: FSMContext):
promo_code = message.text.strip().upper()
promo = await db.get_promo_code(promo_code)
if promo:
discount = promo['discount']
is_unlimited = promo['is_unlimited']
bonus_days = promo['bonus_days']
is_sticky = promo['is_sticky']
await state.update_data(
promo_code=promo_code,
discount=discount,
is_unlimited_promo=is_unlimited,
bonus_days=bonus_days
)
# VIP Check
if is_unlimited:
await state.update_data(final_price=0, discount=100)
await message.answer(
f"🌟 VIP Промокод активирован!\n"
f"Вы получите БЕЗЛИМИТНЫЙ доступ.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🚀 Активировать VIP", callback_data="pay_now")]
])
)
return
data = await state.get_data()
if 'selected_plan' in data:
plan_id = data['selected_plan']
plan = PLANS[plan_id]
# Recalculate with effective discount logic
user = await db.get_user(message.from_user.id)
personal_dist = user['personal_discount'] if user and user['personal_discount'] else 0
effective_discount = max(personal_dist, discount)
final_price = await calculate_final_price(plan['price'], effective_discount)
await state.update_data(final_price=final_price)
msg_text = ""
if bonus_days > 0 and discount == 0:
# Only bonus
msg_text += f"🎁 Бонус-код активирован! +{bonus_days} дней к тарифу.\n"
else:
msg_text += f"✅ Промокод на скидку {discount}% активирован!\n"
if is_sticky:
msg_text += "🔐 <b>Эта скидка закрепится за вами НАВСЕГДА после оплаты!</b>\n"
if personal_dist > discount:
msg_text += f"⚠️ У вас уже есть персональная скидка ({personal_dist}%), которая больше. Будет использована она."
msg_text += f"\nИтоговая цена: {final_price}"
await message.answer(
msg_text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Оплатить", callback_data="pay_now")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="buy_subscription")],
])
)
else:
# Main menu activation logic
msg = f"✅ Промокод {promo_code} принят!"
if is_sticky:
msg += "\n🔐 Скидка будет закреплена за вами при следующей оплате."
await message.answer(
msg,
reply_markup=main_keyboard(message.from_user.id in CONFIG["ADMIN_IDS"])
)
else:
# Check context
data = await state.get_data()
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="enter_promo")],
[InlineKeyboardButton(text="◀️ Отмена", callback_data="back_to_main")],
])
if 'selected_plan' in data:
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="enter_promo")],
[InlineKeyboardButton(text="💳 Оплатить без промокода", callback_data="pay_now")],
])
await message.answer("❌ Промокод недействителен, просрочен или исчерпан.", reply_markup=kb)
await state.set_state(None)
@router.callback_query(F.data == "use_promo")
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 == "reset_promo")
async def reset_promo(callback: CallbackQuery, state: FSMContext):
await state.update_data(promo_code=None, discount=None, final_price=None, is_unlimited_promo=False)
data = await state.get_data()
plan_id = data.get('selected_plan')
if plan_id:
plan = PLANS[plan_id]
await callback.message.edit_text(
f"Вы выбрали: {plan['name']}\n"
f"Стоимость: {plan['price']}\n\n"
f"📦 Трафик: {plan['data_limit']} ГБ\n"
f"⏰ Период: {plan['days']} дней\n\n"
"Есть промокод на скидку?",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Ввести промокод", callback_data="enter_promo")],
[InlineKeyboardButton(text="💳 Оплатить", callback_data="pay_now")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="buy_subscription")],
])
)
else:
await show_plans(callback)
@router.callback_query(F.data == "help")
async def help_handler(callback: CallbackQuery):
help_text = (
" **Помощь и Инструкции**\n\n"
"**Как настроить VPN?**\n"
"1⃣ Нажмите «Моя подписка».\n"
"2⃣ Скопируйте ссылку-конфиг (начинается с `vless://`).\n"
"3⃣ Откройте приложение V2Ray/Hiddify и вставьте ссылку из буфера.\n"
"4⃣ Нажмите кнопку подключения (большая кнопка).\n\n"
"<EFBFBD> **Приложения для скачивания:**\n\n"
"🍏 **iOS (iPhone/iPad):**\n"
"• [V2Box](https://apps.apple.com/us/app/v2box-v2ray-client/id6446814690)\n"
"• [FoXray](https://apps.apple.com/us/app/foxray/id6448898396)\n"
"• [Shadowrocket](https://apps.apple.com/us/app/shadowrocket/id932747118) (Платное, но лучшее)\n\n"
"🤖 **Android:**\n"
"• [v2rayNG](https://play.google.com/store/apps/details?id=com.v2ray.ang)\n"
"• [Hiddify Next](https://play.google.com/store/apps/details?id=app.hiddify.com)\n\n"
"💻 **Windows:**\n"
"• [v2rayN](https://github.com/2dust/v2rayN/releases)\n"
"• [Hiddify Next](https://github.com/hiddify/hiddify-next/releases)\n\n"
"❓ **Проблемы?**\n"
"Если не подключается — попробуйте обновить подписку или напишите админу: @hoshimach1"
)
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, parse_mode="Markdown")
else:
await callback.message.edit_text(help_text, reply_markup=kb, parse_mode="Markdown", disable_web_page_preview=True)
@router.callback_query(F.data == "back_to_main")
async def back_to_main(callback: CallbackQuery):
user = await db.get_user(callback.from_user.id)
is_admin = callback.from_user.id in CONFIG["ADMIN_IDS"]
active = is_active(user)
text = "Главное меню:"
kb = main_keyboard(is_admin, active)
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)

45
keyboards.py Normal file
View File

@@ -0,0 +1,45 @@
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton, WebAppInfo
from config import CONFIG, PLANS
def main_keyboard(is_admin: bool = False, has_active_sub: bool = False) -> InlineKeyboardMarkup:
buttons = []
# Web App Button
if CONFIG["BASE_URL"]:
buttons.append([InlineKeyboardButton(text="🚀 Открыть приложение", web_app=WebAppInfo(url=CONFIG["BASE_URL"]))])
# Если подписки нет (или истекла), показываем кнопку покупки на главном
if not has_active_sub:
buttons.append([InlineKeyboardButton(text="🛒 Купить подписку", callback_data="buy_subscription")])
buttons.append([InlineKeyboardButton(text="📊 Моя подписка", callback_data="my_subscription")])
buttons.append([InlineKeyboardButton(text="🎟 Активировать промокод", callback_data="use_promo")])
buttons.append([InlineKeyboardButton(text=" Помощь", callback_data="help")])
if is_admin:
buttons.append([InlineKeyboardButton(text="👑 Админ-панель", callback_data="admin_panel")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def plans_keyboard() -> InlineKeyboardMarkup:
buttons = []
for plan_id, plan in PLANS.items():
buttons.append([
InlineKeyboardButton(
text=f"{plan['name']} - {plan['price']}",
callback_data=f"plan_{plan_id}"
)
])
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="back_to_main")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def admin_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="👥 Управление пользователями", callback_data="admin_users_list")],
[InlineKeyboardButton(text="📊 Статистика бота", callback_data="admin_stats")],
[InlineKeyboardButton(text="🖥 Статистика сервера", callback_data="admin_server_stats")],
[InlineKeyboardButton(text="📢 Рассылка", callback_data="admin_broadcast")],
[InlineKeyboardButton(text=" Добавить инвайт", callback_data="admin_add_invite")],
[InlineKeyboardButton(text="🏷 Управление промокодами", callback_data="admin_promos")],
[InlineKeyboardButton(text="◀️ В главное меню", callback_data="back_to_main")]
])

1051
main.py

File diff suppressed because it is too large Load Diff

116
marzban.py Normal file
View File

@@ -0,0 +1,116 @@
import logging
import aiohttp
from datetime import datetime, timedelta
from config import CONFIG
logger = logging.getLogger(__name__)
class MarzbanAPI:
def __init__(self, url: str, username: str, password: str):
self.url = url.rstrip('/')
self.username = username
self.password = password
self.token = None
self.session = None
async def init_session(self):
self.session = aiohttp.ClientSession()
async def close_session(self):
if self.session:
await self.session.close()
async def login(self):
async with self.session.post(
f"{self.url}/api/admin/token",
data={"username": self.username, "password": self.password}
) as resp:
data = await resp.json()
self.token = data["access_token"]
return self.token
async def _request(self, method: str, endpoint: str, **kwargs):
if not self.token:
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, 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, url, headers=headers, **kwargs
) as retry_resp:
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, note: str = ""):
if expire_days > 0:
expire_timestamp = int((datetime.now() + timedelta(days=expire_days)).timestamp())
else:
expire_timestamp = None
payload = {
"username": username,
"proxies": {
"vless": {}
},
"inbounds": {},
"excluded_inbounds": {},
"data_limit": data_limit * 1024 * 1024 * 1024,
"data_limit_reset_strategy": "month",
"expire": expire_timestamp,
"status": "active",
"note": note
}
return await self._request("POST", "/user", json=payload)
async def get_user(self, username: str):
return await self._request("GET", f"/user/{username}")
async def modify_user(self, username: str, data_limit: int, expire_days: int = None, status: str = "active", note: str = "", expire_timestamp: int = None):
if expire_timestamp is not None:
final_expire = expire_timestamp
elif expire_days is not None and expire_days > 0:
final_expire = int((datetime.now() + timedelta(days=expire_days)).timestamp())
else:
final_expire = None
payload = {
"data_limit": data_limit * 1024 * 1024 * 1024,
"data_limit_reset_strategy": "month",
"expire": final_expire,
"excluded_inbounds": {},
"status": status,
"note": note
}
return await self._request("PUT", f"/user/{username}", json=payload)
async def delete_user(self, username: str):
return await self._request("DELETE", f"/user/{username}")
async def get_system_stats(self):
return await self._request("GET", "/system")
async def get_users_stats(self):
return await self._request("GET", "/users")
async def reset_user_traffic(self, username: str):
return await self._request("POST", f"/user/{username}/reset")
marzban = MarzbanAPI(
CONFIG["MARZBAN_URL"],
CONFIG["MARZBAN_USERNAME"],
CONFIG["MARZBAN_PASSWORD"]
)

119
server.py Normal file
View File

@@ -0,0 +1,119 @@
from fastapi import FastAPI, HTTPException, Request
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import uvicorn
from datetime import datetime
import logging
import json
from database import db
from config import PLANS
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("server")
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
async def startup():
await db.connect()
logger.info("Database connected")
@app.get("/api/plans")
async def get_plans():
# Convert PLANS dict to list for easier frontend consumption
plans_list = []
for pid, p in PLANS.items():
plans_list.append({
"id": pid,
**p
})
return plans_list
from aiogram.types import LabeledPrice
from pydantic import BaseModel
class BuyPlanRequest(BaseModel):
user_id: int
plan_id: str
@app.post("/api/create-invoice")
async def create_invoice(req: BuyPlanRequest, request: Request):
bot = getattr(request.app.state, "bot", None)
if not bot:
return JSONResponse(status_code=500, content={"error": "Bot instance not initialized in app state"})
plan = PLANS.get(req.plan_id)
if not plan:
return JSONResponse(status_code=404, content={"error": "Plan not found"})
# Determine price in Stars (XTR). Assuming Plan Price in config is in RUB, need conversion or direct usage.
# Telegram Stars usually 1 Star ~= 0.013 USD? Or direct mapping.
# User's bot code uses currency="XTR" and prices=[LabeledPrice(..., amount=final_price)].
# Usually amount is in smallest units? XTR amount is integer number of stars.
# Assuming config price IS stars or directly usable.
price = plan['price']
try:
invoice_link = await bot.create_invoice_link(
title=f"Sub: {plan['name']}",
description=f"{plan['data_limit']}GB / {plan['days']} days",
payload=f"{req.plan_id}:", # Promo code empty for now
provider_token="", # Empty for Stars
currency="XTR",
prices=[LabeledPrice(label=plan['name'], amount=price)]
)
return {"invoice_link": invoice_link}
except Exception as e:
logger.error(f"Error generating invoice: {e}")
return JSONResponse(status_code=500, content={"error": str(e)})
@app.get("/api/user/{user_id}")
async def get_user_stats(user_id: int):
user = await db.get_user(user_id)
if not user:
return JSONResponse(status_code=404, content={"error": "User not found"})
sub_until = user['subscription_until']
days_left = 0
status = "Inactive"
expire_str = "-"
if sub_until:
if isinstance(sub_until, str):
try:
sub_until = datetime.fromisoformat(sub_until)
except:
pass
if isinstance(sub_until, datetime):
expire_str = sub_until.strftime("%Y-%m-%d")
if sub_until > datetime.now():
delta = sub_until - datetime.now()
days_left = delta.days
status = "Active"
else:
status = "Expired"
return {
"status": status,
"days_left": days_left,
"expire_date": expire_str,
"data_usage": user['data_limit'] or 0,
"plan": "Custom"
}
# Serve Static Files (must be last)
app.mount("/", StaticFiles(directory="web_app/static", html=True), name="static")
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)

22
states.py Normal file
View File

@@ -0,0 +1,22 @@
from aiogram.fsm.state import State, StatesGroup
class InviteStates(StatesGroup):
waiting_for_code = State()
class PromoStates(StatesGroup):
waiting_for_promo = State()
waiting_for_name = State()
waiting_for_discount = State()
waiting_for_bonus = State()
waiting_for_uses = State()
waiting_for_days = State()
class BroadcastStates(StatesGroup):
waiting_for_message = State()
confirm_broadcast = State()
class AdminUserStates(StatesGroup):
waiting_for_search = State()
waiting_for_days = State()
waiting_for_message = State()
waiting_for_limit = State()

View File

@@ -0,0 +1,373 @@
:root {
--bg-color: #050510;
--glass-bg: rgba(255, 255, 255, 0.05);
--glass-border: rgba(255, 255, 255, 0.1);
--glass-highlight: rgba(255, 255, 255, 0.15);
--primary: #6366f1;
--primary-glow: rgba(99, 102, 241, 0.5);
--text-main: #ffffff;
--text-muted: #94a3b8;
--radius: 16px;
--font-main: 'Outfit', sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-color);
color: var(--text-main);
font-family: var(--font-main);
overflow: hidden;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
#stars-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
background: radial-gradient(ellipse at bottom, #1b2735 0%, #090a0f 100%);
}
.app-container {
display: flex;
width: 95vw;
height: 90vh;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
background: rgba(15, 23, 42, 0.3);
backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
}
/* Sidebar */
.sidebar {
width: 250px;
background: rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
padding: 24px;
border-right: 1px solid var(--glass-border);
}
.logo {
display: flex;
align-items: center;
gap: 12px;
font-size: 24px;
font-weight: 700;
color: var(--text-main);
margin-bottom: 40px;
}
.logo-icon {
color: var(--primary);
filter: drop-shadow(0 0 8px var(--primary-glow));
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
margin-bottom: 8px;
background: transparent;
border: none;
color: var(--text-muted);
font-size: 16px;
font-family: inherit;
cursor: pointer;
border-radius: 12px;
transition: all 0.3s ease;
width: 100%;
text-align: left;
}
.nav-item:hover {
background: var(--glass-bg);
color: var(--text-main);
}
.nav-item.active {
background: var(--primary);
color: white;
box-shadow: 0 4px 12px var(--primary-glow);
}
.user-mini {
margin-top: auto;
display: flex;
align-items: center;
gap: 12px;
padding-top: 20px;
border-top: 1px solid var(--glass-border);
}
.avatar,
.big-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #a855f7, #6366f1);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: white;
}
.big-avatar {
width: 80px;
height: 80px;
font-size: 32px;
margin-bottom: 16px;
}
.info {
display: flex;
flex-direction: column;
}
.name {
font-weight: 600;
font-size: 14px;
}
.status {
font-size: 12px;
color: #4ade80;
}
/* Main Content */
.content {
flex: 1;
display: flex;
flex-direction: column;
}
header {
height: 80px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px;
border-bottom: 1px solid var(--glass-border);
}
.glass {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
backdrop-filter: blur(12px);
border-radius: var(--radius);
}
.input-glass {
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--glass-border);
color: white;
padding: 10px;
border-radius: 8px;
font-family: inherit;
}
.icon-btn {
background: transparent;
border: none;
color: var(--text-main);
padding: 8px;
cursor: pointer;
border-radius: 50%;
transition: background 0.2s;
}
.icon-btn:hover {
background: var(--glass-bg);
}
.view-container {
padding: 32px;
overflow-y: auto;
flex: 1;
}
/* Dashboard Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
margin-bottom: 32px;
}
.stat-card {
padding: 24px;
display: flex;
align-items: center;
gap: 16px;
}
.icon-box {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
}
.stat-info h3 {
font-size: 14px;
color: var(--text-muted);
font-weight: 500;
}
.stat-info .value {
font-size: 24px;
font-weight: 700;
margin-top: 4px;
}
.sub-details {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
max-width: 500px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--glass-border);
}
.btn-primary {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white;
border: none;
padding: 12px 24px;
border-radius: 12px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 15px var(--primary-glow);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px var(--primary-glow);
}
.btn-primary.full-width {
width: 100%;
margin-top: 16px;
}
/* Shop */
.plans-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.plan-card {
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
transition: transform 0.3s;
}
.plan-card:hover {
transform: translateY(-5px);
border-color: var(--primary);
}
.plan-name {
font-size: 20px;
font-weight: 700;
margin-bottom: 8px;
}
.plan-price {
font-size: 32px;
font-weight: 800;
color: var(--primary);
margin-bottom: 24px;
}
.plan-features {
list-style: none;
margin-bottom: 24px;
width: 100%;
}
.plan-features li {
padding: 8px 0;
border-bottom: 1px solid var(--glass-border);
color: var(--text-muted);
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.view-container>* {
animation: fadeIn 0.4s ease-out;
}
/* Mobile */
@media (max-width: 768px) {
.app-container {
width: 100vw;
height: 100vh;
border-radius: 0;
flex-direction: column;
}
.sidebar {
width: 100%;
height: auto;
flex-direction: row;
padding: 12px;
align-items: center;
justify-content: space-between;
}
.sidebar nav {
display: none;
/* Mobile nav needs a toggle, simplifying for now */
}
.user-mini {
padding: 0;
border: none;
margin: 0;
}
.stats-grid {
grid-template-columns: 1fr;
}
}

130
web_app/static/index.html Normal file
View File

@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Marzban Bot Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="css/style.css">
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body>
<div id="stars-container"></div>
<div class="app-container">
<aside class="sidebar glass">
<div class="logo">
<i data-lucide="rocket" class="logo-icon"></i>
<span>CometBot</span>
</div>
<nav>
<button class="nav-item active" data-page="dashboard" onclick="router('dashboard')">
<i data-lucide="layout-dashboard"></i> Dashboard
</button>
<button class="nav-item" data-page="shop" onclick="router('shop')">
<i data-lucide="shopping-bag"></i> Shop
</button>
<button class="nav-item" data-page="profile" onclick="router('profile')">
<i data-lucide="user"></i> Profile
</button>
</nav>
<div class="user-mini">
<div class="avatar" id="sidebar-avatar">U</div>
<div class="info">
<span class="name" id="sidebar-name">User</span>
<span class="status">Online</span>
</div>
</div>
</aside>
<main class="content">
<header class="glass">
<h1 id="page-title">Dashboard</h1>
<div class="actions">
<button class="icon-btn"><i data-lucide="bell"></i></button>
<button class="icon-btn theme-toggle"><i data-lucide="sun"></i></button>
</div>
</header>
<div id="app-view" class="view-container">
<!-- Dynamic Content Loaded Here -->
<div class="loading-spinner"></div>
</div>
</main>
</div>
<!-- Templates for Views -->
<template id="view-dashboard">
<div class="stats-grid">
<div class="card glass stat-card">
<div class="icon-box <color>"><i data-lucide="activity"></i></div>
<div class="stat-info">
<h3>Status</h3>
<p class="value" id="dash-status">Active</p>
</div>
</div>
<div class="card glass stat-card">
<div class="icon-box"><i data-lucide="calendar"></i></div>
<div class="stat-info">
<h3>Days Left</h3>
<p class="value" id="dash-days">0</p>
</div>
</div>
<div class="card glass stat-card">
<div class="icon-box"><i data-lucide="wifi"></i></div>
<div class="stat-info">
<h3>Data Usage</h3>
<p class="value" id="dash-data">0 GB</p>
</div>
</div>
</div>
<div class="section-title">
<h2>Your Subscription</h2>
</div>
<div class="card glass sub-details">
<div class="detail-row">
<span>Plan</span>
<strong id="sub-plan-name">Free Tier</strong>
</div>
<div class="detail-row">
<span>Expires</span>
<strong id="sub-expire-date">-</strong>
</div>
<button class="btn-primary full-width" onclick="router('shop')">Upgrade Plan</button>
</div>
</template>
<template id="view-shop">
<div class="plans-grid" id="plans-container">
<!-- Plans will be injected here -->
</div>
</template>
<template id="view-profile">
<div class="card glass profile-card">
<div class="profile-header">
<div class="big-avatar">U</div>
<h2>User Profile</h2>
<p>Telegram ID: <span id="profile-tg-id">123456</span></p>
</div>
<div class="form-group">
<label>Username</label>
<input type="text" id="profile-username" readonly value="@username" class="glass-input">
</div>
</div>
</template>
<script src="js/background.js"></script>
<script src="js/app.js"></script>
<script>
lucide.createIcons();
</script>
</body>
</html>

183
web_app/static/js/app.js Normal file
View File

@@ -0,0 +1,183 @@
// Navigation Router
function router(pageName) {
const viewContainer = document.getElementById('app-view');
const title = document.getElementById('page-title');
const navItems = document.querySelectorAll('.nav-item');
// Update Nav
navItems.forEach(item => {
if (item.dataset.page === pageName) item.classList.add('active');
else item.classList.remove('active');
});
// Set Title
title.textContent = pageName.charAt(0).toUpperCase() + pageName.slice(1);
// Load View
const template = document.getElementById(`view-${pageName}`);
if (template) {
viewContainer.innerHTML = '';
viewContainer.appendChild(template.content.cloneNode(true));
// Initialize view specific logic
if (pageName === 'dashboard') loadDashboard();
if (pageName === 'shop') loadShop();
if (pageName === 'profile') loadProfile();
// Re-init generic UI stuff like icons if new ones added
if (window.lucide) lucide.createIcons();
}
}
// Data Fetching
const API_BASE = '/api';
// Telegram Integration
let tgUser = null;
if (window.Telegram && window.Telegram.WebApp) {
const tg = window.Telegram.WebApp;
tg.ready();
tgUser = tg.initDataUnsafe?.user;
// Theme sync
if (tg.colorScheme === 'dark') document.body.classList.add('dark');
// Expand
tg.expand();
}
// Fallback for browser testing
if (!tgUser) {
console.warn("No Telegram user detected, using mock user");
tgUser = { id: 123456789, first_name: 'Test', username: 'testuser' };
}
// Update UI with User Info
const sidebarName = document.getElementById('sidebar-name');
const sidebarAvatar = document.getElementById('sidebar-avatar');
if (sidebarName) sidebarName.textContent = tgUser.first_name || tgUser.username;
if (sidebarAvatar) sidebarAvatar.textContent = (tgUser.first_name || 'U')[0].toUpperCase();
async function loadDashboard() {
try {
const res = await fetch(`${API_BASE}/user/${tgUser.id}`);
if (!res.ok) throw new Error("Failed to fetch user");
const data = await res.json();
const statusEl = document.getElementById('dash-status');
const daysEl = document.getElementById('dash-days');
const dataEl = document.getElementById('dash-data');
const planEl = document.getElementById('sub-plan-name');
const expireEl = document.getElementById('sub-expire-date');
if (statusEl) statusEl.textContent = data.status;
if (daysEl) daysEl.textContent = data.days_left;
if (dataEl) dataEl.textContent = `${data.data_usage || 0} GB`;
if (planEl) planEl.textContent = data.plan;
if (expireEl) expireEl.textContent = data.expire_date;
// Colorize status
if (data.status === 'Active') {
document.querySelector('.stat-info .value').style.color = '#4ade80';
} else {
document.querySelector('.stat-info .value').style.color = '#f87171';
}
} catch (e) {
console.error(e);
// Show error state?
}
}
async function loadShop() {
const container = document.getElementById('plans-container');
if (!container) return;
container.innerHTML = '<div class="loading-spinner">Loading plans...</div>';
try {
const res = await fetch(`${API_BASE}/plans`);
if (!res.ok) throw new Error("Failed to fetch plans");
const plans = await res.json();
container.innerHTML = '';
plans.forEach(plan => {
const card = document.createElement('div');
card.className = 'card glass plan-card';
// Features list generation
const features = [
`${plan.data_limit} GB Data`,
`${plan.days} Days`,
'High Speed'
];
card.innerHTML = `
<div class="plan-name">${plan.name}</div>
<div class="plan-price">${plan.price} XTR</div>
<ul class="plan-features">
${features.map(f => `<li>${f}</li>`).join('')}
</ul>
<button class="btn-primary" onclick="buyPlan('${plan.id}')">Buy for ${plan.price}</button>
`;
container.appendChild(card);
});
} catch (e) {
container.innerHTML = 'Error loading plans.';
}
}
async function buyPlan(planId) {
if (!window.Telegram || !window.Telegram.WebApp) {
alert("Payment only works inside Telegram!");
return;
}
const btn = document.activeElement;
const originalText = btn.innerText;
btn.innerText = 'Creating Invoice...';
btn.disabled = true;
try {
const res = await fetch(`${API_BASE}/create-invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: tgUser.id,
plan_id: planId
})
});
const data = await res.json();
if (data.invoice_link) {
window.Telegram.WebApp.openInvoice(data.invoice_link, (status) => {
if (status === 'paid') {
window.Telegram.WebApp.showAlert('Payment Successful! Subscription activated.');
router('dashboard');
} else if (status === 'cancelled') {
// User cancelled
} else {
window.Telegram.WebApp.showAlert('Payment failed or pending.');
}
});
} else {
window.Telegram.WebApp.showAlert('Error creating invoice: ' + data.error);
}
} catch (e) {
window.Telegram.WebApp.showAlert('Network error');
console.error(e);
} finally {
btn.innerText = originalText;
btn.disabled = false;
}
}
async function loadProfile() {
document.getElementById('profile-tg-id').textContent = tgUser.id;
document.getElementById('profile-username').value = '@' + (tgUser.username || 'unknown');
}
// Init
router('dashboard');

View File

@@ -0,0 +1,144 @@
const container = document.getElementById('stars-container');
// Create Canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
container.appendChild(canvas);
let width, height;
let stars = [];
let comets = [];
function resize() {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width;
canvas.height = height;
initStars();
}
class Star {
constructor() {
this.reset();
this.y = Math.random() * height; // Initial random y
}
reset() {
this.x = Math.random() * width;
this.y = Math.random() * height;
this.z = Math.random() * 2 + 0.5; // Depth/Size/Speed
this.baseSize = Math.random() * 1.5;
this.alpha = Math.random() * 0.5 + 0.1;
this.twinkle = Math.random() * 0.05;
}
update() {
this.alpha += this.twinkle;
if (this.alpha > 0.8 || this.alpha < 0.1) this.twinkle = -this.twinkle;
}
draw() {
ctx.fillStyle = `rgba(255, 255, 255, ${this.alpha})`;
ctx.beginPath();
const size = this.baseSize * (this.z / 2);
ctx.arc(this.x, this.y, size, 0, Math.PI * 2);
ctx.fill();
}
}
class Comet {
constructor() {
this.reset();
}
reset() {
this.x = Math.random() * width;
this.y = Math.random() * height * 0.5;
this.len = Math.random() * 80 + 20;
this.speed = Math.random() * 5 + 2;
this.angle = Math.PI / 4 + (Math.random() - 0.5) * 0.2; // 45 degrees
this.active = false;
this.wait = Math.random() * 200 + 50;
}
update() {
if (!this.active) {
this.wait--;
if (this.wait <= 0) {
this.active = true;
this.x = Math.random() * width - 200; // Start off screen slightly
this.y = Math.random() * height * 0.5;
}
return;
}
this.x += Math.cos(this.angle) * this.speed;
this.y += Math.sin(this.angle) * this.speed;
if (this.x > width + 100 || this.y > height + 100) {
this.active = false;
this.reset();
this.wait = Math.random() * 500 + 100; // Wait longer before next
}
}
draw() {
if (!this.active) return;
// Gradient tail
const grad = ctx.createLinearGradient(
this.x, this.y,
this.x - Math.cos(this.angle) * this.len,
this.y - Math.sin(this.angle) * this.len
);
grad.addColorStop(0, 'rgba(255, 255, 255, 0.8)');
grad.addColorStop(1, 'rgba(99, 102, 241, 0)'); // Fade to purple blue
ctx.strokeStyle = grad;
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(this.x, this.y);
ctx.lineTo(
this.x - Math.cos(this.angle) * this.len,
this.y - Math.sin(this.angle) * this.len
);
ctx.stroke();
// Head
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(this.x, this.y, 2, 0, Math.PI * 2);
ctx.fill();
// Glow
ctx.shadowBlur = 10;
ctx.shadowColor = '#6366f1';
ctx.fill();
ctx.shadowBlur = 0;
}
}
function initStars() {
stars = [];
comets = [];
for (let i = 0; i < 150; i++) stars.push(new Star());
for (let i = 0; i < 3; i++) comets.push(new Comet());
}
function animate() {
ctx.clearRect(0, 0, width, height);
// Background gradient for depth
// ctx.fillStyle = 'rgba(5, 5, 16, 0.2)';
// ctx.fillRect(0,0,width,height);
stars.forEach(s => { s.update(); s.draw(); });
comets.forEach(c => { c.update(); c.draw(); });
requestAnimationFrame(animate);
}
window.addEventListener('resize', resize);
resize();
animate();