Улучшение взаимодействия и добавление веб-приложения
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_USERNAME=admin
|
||||
MARZBAN_PASSWORD=rY4tU8hX4nqF
|
||||
BASE_URL=https://proxy.stellarisei.ru/
|
||||
# Оставьте пустым для использования SQLite (создаст файл bot.db)
|
||||
# DATABASE_URL=postgresql://user:password@localhost/vpnbot
|
||||
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