Улучшение взаимодействия и добавление веб-приложения
This commit is contained in:
1
.env
1
.env
@@ -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
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
bot.db
|
||||||
|
.env
|
||||||
309
database.py
Normal file
309
database.py
Normal 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
7
handlers/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from . import user, admin, payment
|
||||||
|
|
||||||
|
routers = [
|
||||||
|
user.router,
|
||||||
|
admin.router,
|
||||||
|
payment.router
|
||||||
|
]
|
||||||
735
handlers/admin.py
Normal file
735
handlers/admin.py
Normal 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
217
handlers/payment.py
Normal 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
477
handlers/user.py
Normal 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
45
keyboards.py
Normal 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")]
|
||||||
|
])
|
||||||
116
marzban.py
Normal file
116
marzban.py
Normal 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
119
server.py
Normal 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
22
states.py
Normal 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()
|
||||||
373
web_app/static/css/style.css
Normal file
373
web_app/static/css/style.css
Normal 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
130
web_app/static/index.html
Normal 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
183
web_app/static/js/app.js
Normal 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');
|
||||||
144
web_app/static/js/background.js
Normal file
144
web_app/static/js/background.js
Normal 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();
|
||||||
Reference in New Issue
Block a user