diff --git a/api.py b/api.py
new file mode 100644
index 0000000..12494f3
--- /dev/null
+++ b/api.py
@@ -0,0 +1,549 @@
+import datetime
+import json
+import os
+from fastapi import FastAPI, HTTPException, Request, Response
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi import Body
+from fastapi.responses import HTMLResponse, PlainTextResponse, FileResponse
+
+import db
+import links
+import nodeprov
+import xray_manager
+from config import (
+ PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN,
+ ADMIN_PANEL_PASSWORD, BOT_USERNAME,
+)
+
+db.init_db()
+
+app = FastAPI(title="mbs-api")
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=[f"https://{SITE_DOMAIN}", f"https://www.{SITE_DOMAIN}", f"https://{PANEL_DOMAIN}"],
+ allow_methods=["GET", "POST", "PATCH", "DELETE"],
+ allow_headers=["*"],
+ allow_credentials=True,
+)
+
+ADMIN_COOKIE = "mbs_admin"
+
+
+def require_admin(request: Request):
+ token = request.cookies.get(ADMIN_COOKIE)
+ if not db.validate_admin_session(token):
+ raise HTTPException(401, "unauthorized")
+
+
+def _days_left(expires_at: str) -> int:
+ exp = datetime.datetime.fromisoformat(expires_at)
+ delta = exp - datetime.datetime.utcnow()
+ return max(0, delta.days)
+
+
+CLIENT_UA_MARKERS = (
+ "happ", "v2ray", "v2box", "nekoray", "nekobox", "clash", "hiddify",
+ "streisand", "shadowrocket", "sing-box", "singbox", "karing", "loon",
+ "quantumult", "surge", "stash",
+)
+
+
+def _is_app_client(user_agent: str) -> bool:
+ ua = (user_agent or "").lower()
+ return any(m in ua for m in CLIENT_UA_MARKERS)
+
+
+SUB_PAGE_TEMPLATE = """
+
+
+
+
+MBS Panel — подписка
+
+
+
+
+
MBS Panel
+
Подписка готова
+
Нажми кнопку — сервер добавится в Happ автоматически, или отсканируй QR другим устройством
+
Добавить в Happ
+
Открыть ссылку подписки
+
+
{sub_url}
+
Спасибо, что пользуетесь MBS Panel.
Если Happ не установлен — скачай его в App Store или Google Play.
+
+
+
+
+"""
+
+
+SUB_PAGE_EXPIRED_TEMPLATE = """
+
+
+
+
+MBS Panel — подписка
+
+
+
+
+
MBS Panel
+
Подписка истекла
+
Доступ по этой ссылке закончился. Продли подписку в боте — ссылка останется той же, ничего заново настраивать не нужно.
+
Продлить в боте
+
+
+"""
+
+
+@app.get("/healthz")
+def healthz():
+ return {"ok": True}
+
+
+@app.get("/sub/{token}")
+def get_subscription(token: str, request: Request):
+ user = db.get_user_by_token(token)
+ if not user:
+ raise HTTPException(404, "not found")
+ subs = db.list_active_subscriptions(tg_id=user["tg_id"])
+ ua = request.headers.get("user-agent", "")
+ if not _is_app_client(ua):
+ if not subs:
+ return HTMLResponse(SUB_PAGE_EXPIRED_TEMPLATE.format(bot_username=BOT_USERNAME))
+ sub_url = f"https://{SUB_DOMAIN}/sub/{token}"
+ return HTMLResponse(SUB_PAGE_TEMPLATE.format(sub_url=sub_url))
+ content = links.build_subscription_text(subs)
+ return Response(content=content, media_type="text/plain")
+
+
+@app.get("/api/cabinet/{token}")
+def cabinet(token: str):
+ user = db.get_user_by_token(token)
+ if not user:
+ raise HTTPException(404, "not found")
+ subs = db.list_active_subscriptions(tg_id=user["tg_id"])
+ out = []
+ for s in subs:
+ plan = PLANS_BY_CODE.get(s["plan"])
+ out.append({
+ "node": s["node"],
+ "plan": s["plan"],
+ "plan_label": plan["label"] if plan else s["plan"],
+ "expires_at": s["expires_at"],
+ "days_left": _days_left(s["expires_at"]),
+ })
+ return {
+ "username": user["username"],
+ "subscriptions": out,
+ "sub_link": f"https://{SUB_DOMAIN}/sub/{token}",
+ "nodes_available": [n["label"] for n in db.list_nodes(enabled_only=True)],
+ }
+
+
+
+@app.get("/mgmt-pubkey.txt", response_class=PlainTextResponse)
+def mgmt_pubkey():
+ return nodeprov.ensure_mgmt_key() + "\n"
+
+
+@app.get("/install/{token}.sh", response_class=PlainTextResponse)
+def install_script(token: str):
+ node = db.get_node_by_token(token)
+ if not node:
+ raise HTTPException(404, "unknown token")
+ return nodeprov.render_install_script(node)
+
+
+@app.post("/nodes/register/{token}")
+async def register_node(token: str, request: Request):
+ node = db.get_node_by_token(token)
+ if not node:
+ raise HTTPException(404, "unknown token")
+ try:
+ body = await request.json()
+ except Exception:
+ body = {}
+ if body.get("status") == "active":
+ db.activate_node(node["code"])
+ return {"ok": True}
+
+
+
+@app.post("/admin/api/login")
+def admin_login(response: Response, body: dict = Body(...)):
+ if body.get("password") != ADMIN_PANEL_PASSWORD:
+ raise HTTPException(401, "wrong password")
+ token = db.create_admin_session()
+ response.set_cookie(ADMIN_COOKIE, token, httponly=True, secure=True, samesite="strict", max_age=7 * 24 * 3600)
+ return {"ok": True}
+
+
+@app.post("/admin/api/logout")
+def admin_logout(request: Request, response: Response):
+ token = request.cookies.get(ADMIN_COOKIE)
+ if token:
+ db.delete_admin_session(token)
+ response.delete_cookie(ADMIN_COOKIE)
+ return {"ok": True}
+
+
+@app.get("/admin/api/me")
+def admin_me(request: Request):
+ token = request.cookies.get(ADMIN_COOKIE)
+ return {"authenticated": db.validate_admin_session(token)}
+
+
+
+@app.get("/admin/api/stats")
+def admin_stats(request: Request):
+ require_admin(request)
+ return db.stats()
+
+
+def _fmt_bytes(n: int) -> str:
+ v = float(n)
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
+ if v < 1024 or unit == "TB":
+ return f"{v:.1f} {unit}" if unit != "B" else f"{int(v)} {unit}"
+ v /= 1024
+ return f"{v:.1f} TB"
+
+
+@app.get("/admin/api/traffic")
+def admin_traffic(request: Request):
+ require_admin(request)
+ all_stats = dict(xray_manager.query_stats())
+ for node in db.list_nodes():
+ if node["kind"] != "managed" or node["status"] != "active":
+ continue
+ try:
+ remote = nodeprov.remote_query_stats(node)
+ except Exception:
+ remote = {}
+ for k, v in remote.items():
+ if k in all_stats:
+ all_stats[k]["up"] += v["up"]
+ all_stats[k]["down"] += v["down"]
+ else:
+ all_stats[k] = v
+
+ total_up = sum(v["up"] for v in all_stats.values())
+ total_down = sum(v["down"] for v in all_stats.values())
+
+ subs = db.list_all_subscriptions(limit=5000)
+ per_sub = []
+ for s in subs:
+ st = all_stats.get(s["uuid"])
+ if not st:
+ continue
+ node = db.get_node(s["node"])
+ per_sub.append({
+ "uuid": s["uuid"],
+ "username": ("@" + s["username"]) if s.get("username") else f"tg{s['tg_id']}",
+ "node_label": node["label"] if node else s["node"],
+ "up": st["up"], "down": st["down"],
+ "up_fmt": _fmt_bytes(st["up"]), "down_fmt": _fmt_bytes(st["down"]),
+ "total_fmt": _fmt_bytes(st["up"] + st["down"]),
+ })
+ per_sub.sort(key=lambda r: r["up"] + r["down"], reverse=True)
+
+ return {
+ "total_up": total_up, "total_down": total_down,
+ "total_up_fmt": _fmt_bytes(total_up), "total_down_fmt": _fmt_bytes(total_down),
+ "total_fmt": _fmt_bytes(total_up + total_down),
+ "per_subscription": per_sub,
+ }
+
+
+@app.get("/admin/api/subscriptions")
+def admin_subscriptions(request: Request):
+ require_admin(request)
+ subs = db.list_all_subscriptions()
+ out = []
+ for s in subs:
+ node = db.get_node(s["node"])
+ plan = PLANS_BY_CODE.get(s["plan"])
+ out.append({
+ **s,
+ "node_label": node["label"] if node else s["node"],
+ "plan_label": plan["label"] if plan else s["plan"],
+ "days_left": _days_left(s["expires_at"]),
+ })
+ return out
+
+
+@app.post("/admin/api/subscriptions/{uuid}/revoke")
+def admin_revoke_subscription(uuid: str, request: Request):
+ require_admin(request)
+ subs = db.list_all_subscriptions(limit=5000)
+ sub = next((s for s in subs if s["uuid"] == uuid), None)
+ if not sub:
+ raise HTTPException(404, "not found")
+ node = db.get_node(sub["node"])
+ if node:
+ xray_manager.remove_client_from_node(node, uuid)
+ db.revoke_subscription(uuid)
+ return {"ok": True}
+
+
+
+@app.get("/admin/api/gift-codes")
+def admin_gift_codes(request: Request):
+ require_admin(request)
+ codes = db.list_gift_codes()
+ out = []
+ for c in codes:
+ node = db.get_node(c["node"])
+ plan = PLANS_BY_CODE.get(c["plan"])
+ out.append({
+ **c,
+ "node_label": node["label"] if node else c["node"],
+ "plan_label": plan["label"] if plan else c["plan"],
+ "link": f"https://t.me/{BOT_USERNAME}?start=gift_{c['code']}",
+ })
+ return out
+
+
+@app.post("/admin/api/gift-codes")
+def admin_create_gift_code(request: Request, body: dict = Body(...)):
+ require_admin(request)
+ node, plan = body.get("node"), body.get("plan")
+ if node not in {n["code"] for n in db.list_nodes()} or plan not in PLANS_BY_CODE:
+ raise HTTPException(400, "invalid node/plan")
+ code = db.create_gift_code(node, plan, created_by=0)
+ return {"code": code, "link": f"https://t.me/{BOT_USERNAME}?start=gift_{code}"}
+
+
+@app.get("/admin/api/plans")
+def admin_plans(request: Request):
+ require_admin(request)
+ return PLANS
+
+
+
+@app.get("/admin/api/nodes")
+def admin_nodes(request: Request):
+ require_admin(request)
+ nodes = db.list_nodes()
+ for n in nodes:
+ n.pop("private_key", None)
+ n.pop("provision_token", None)
+ return nodes
+
+
+@app.post("/admin/api/nodes")
+def admin_create_node(request: Request, body: dict = Body(...)):
+ require_admin(request)
+ node = db.create_node(
+ code=body["code"], label=body["label"], kind=body.get("kind", "external"),
+ address=body["address"], port=int(body.get("port", 443)),
+ public_key=body["public_key"], short_id=body["short_id"],
+ sni=body["sni"], flow=body.get("flow", "xtls-rprx-vision"),
+ shared_uuid=body.get("shared_uuid"),
+ )
+ return node
+
+
+@app.patch("/admin/api/nodes/{code}")
+def admin_update_node(code: str, request: Request, body: dict = Body(...)):
+ require_admin(request)
+ allowed = {k: v for k, v in body.items() if k in {"label", "enabled", "address", "port", "sni"}}
+ return db.update_node(code, **allowed)
+
+
+@app.delete("/admin/api/nodes/{code}")
+def admin_delete_node(code: str, request: Request):
+ require_admin(request)
+ try:
+ db.delete_node(code)
+ except ValueError as e:
+ raise HTTPException(400, str(e))
+ return {"ok": True}
+
+
+@app.post("/admin/api/nodes/{code}/check")
+def admin_check_node(code: str, request: Request):
+ require_admin(request)
+ node = db.get_node(code)
+ if not node:
+ raise HTTPException(404, "not found")
+ alive = nodeprov.check_node_alive(node["address"], node["port"])
+ return {"alive": alive}
+
+
+def _fmt_uptime(seconds):
+ if seconds is None:
+ return "—"
+ days, rem = divmod(seconds, 86400)
+ hours, rem = divmod(rem, 3600)
+ minutes = rem // 60
+ if days:
+ return f"{days}д {hours}ч"
+ if hours:
+ return f"{hours}ч {minutes}м"
+ return f"{minutes}м"
+
+
+@app.get("/admin/api/nodes/{code}/metrics")
+def admin_node_metrics(code: str, request: Request):
+ require_admin(request)
+ node = db.get_node(code)
+ if not node:
+ raise HTTPException(404, "not found")
+ status = xray_manager.local_node_status() if node["kind"] == "local" else nodeprov.remote_node_status(node)
+ if status.get("ok") and status.get("mem_total_mb"):
+ status["mem_fmt"] = f"{status['mem_used_mb']} / {status['mem_total_mb']} MB"
+ else:
+ status["mem_fmt"] = "—"
+ status["uptime_fmt"] = _fmt_uptime(status.get("uptime_s"))
+ return status
+
+
+@app.post("/admin/api/nodes/provision-guide")
+def admin_provision_guide(request: Request, body: dict = Body(...)):
+ require_admin(request)
+ label = body["label"]
+ address = body["address"]
+ port = int(body.get("port", 443))
+ sni = body.get("sni") or "www.wildberries.ru"
+ include_ws = bool(body.get("include_ws"))
+ include_hysteria2 = bool(body.get("include_hysteria2"))
+
+ private_key, public_key = nodeprov.generate_reality_keys()
+ transports = nodeprov.build_transports(address, port, sni, public_key, include_ws=include_ws)
+ short_id = transports[0]["short_id"]
+
+ hysteria_port = hysteria_password = hysteria_obfs_password = None
+ if include_hysteria2:
+ hysteria_port = int(body.get("hysteria_port", 443))
+ hysteria_password, hysteria_obfs_password = nodeprov.generate_hysteria_credentials()
+
+ node, token = db.create_pending_node(
+ label, address, port, sni, private_key, public_key, short_id,
+ transports_json=json.dumps(transports),
+ hysteria_port=hysteria_port, hysteria_password=hysteria_password,
+ hysteria_obfs_password=hysteria_obfs_password,
+ )
+ return {"code": node["code"], "command": nodeprov.one_command(token)}
+
+
+@app.get("/admin/api/nodes/{code}/status")
+def admin_node_status(code: str, request: Request):
+ require_admin(request)
+ node = db.get_node(code)
+ if not node:
+ raise HTTPException(404, "not found")
+ return {"status": node["status"], "enabled": bool(node["enabled"])}
+
+
+
+ADMIN_HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "admin.html")
+
+
+@app.get("/")
+def root(request: Request):
+ if request.headers.get("host", "").split(":")[0] == PANEL_DOMAIN:
+ return FileResponse(ADMIN_HTML_PATH)
+ raise HTTPException(404)
+
+
+@app.get("/admin")
+def admin_page():
+ return FileResponse(ADMIN_HTML_PATH)
diff --git a/bot.py b/bot.py
new file mode 100644
index 0000000..3605458
--- /dev/null
+++ b/bot.py
@@ -0,0 +1,295 @@
+import asyncio
+import logging
+
+from aiogram import Bot, Dispatcher, F
+from aiogram.filters import CommandStart, CommandObject
+from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
+from aiogram.client.default import DefaultBotProperties
+from aiogram.enums import ParseMode
+
+import db
+import links
+import xray_manager
+from config import BOT_TOKEN, ADMIN_IDS, PLANS, PLANS_BY_CODE, SUB_DOMAIN, SITE_DOMAIN
+
+logging.basicConfig(level=logging.INFO)
+log = logging.getLogger("mbs-bot")
+
+db.init_db()
+
+bot = Bot(token=BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
+dp = Dispatcher()
+
+_bot_username: str | None = None
+
+
+def is_admin(tg_id: int) -> bool:
+ return tg_id in ADMIN_IDS
+
+
+def main_menu_kb(tg_id: int) -> InlineKeyboardMarkup:
+ rows = [
+ [InlineKeyboardButton(text="Получить VPN", callback_data="menu:get")],
+ [InlineKeyboardButton(text="Моя подписка", callback_data="menu:mysub")],
+ [InlineKeyboardButton(text="О сервисе", callback_data="menu:about")],
+ ]
+ if is_admin(tg_id):
+ rows.append([InlineKeyboardButton(text="Админка", callback_data="menu:admin")])
+ return InlineKeyboardMarkup(inline_keyboard=rows)
+
+
+def nodes_kb(prefix: str) -> InlineKeyboardMarkup:
+ rows = []
+ for n in db.list_nodes(enabled_only=True):
+ rows.append([InlineKeyboardButton(text=n["label"], callback_data=f"{prefix}:{n['code']}")])
+ rows.append([InlineKeyboardButton(text="Назад", callback_data="menu:main")])
+ return InlineKeyboardMarkup(inline_keyboard=rows)
+
+
+def plans_kb(prefix: str, node_code: str) -> InlineKeyboardMarkup:
+ rows = []
+ for p in PLANS:
+ rows.append([InlineKeyboardButton(text=p["label"], callback_data=f"{prefix}:{node_code}:{p['code']}")])
+ rows.append([InlineKeyboardButton(text="Назад", callback_data="menu:get")])
+ return InlineKeyboardMarkup(inline_keyboard=rows)
+
+
+DIVIDER = "───────────────"
+
+
+def sub_url_for(token: str) -> str:
+ return f"https://{SUB_DOMAIN}/sub/{token}"
+
+
+def connect_kb(token: str, extra_rows: list[list[InlineKeyboardButton]] | None = None) -> InlineKeyboardMarkup:
+ rows = [[InlineKeyboardButton(text="Подключиться", url=sub_url_for(token))]]
+ if extra_rows:
+ rows.extend(extra_rows)
+ return InlineKeyboardMarkup(inline_keyboard=rows)
+
+
+ABOUT_TEXT = (
+ "MBS Panel\n\n"
+ "Быстрый и незаметный доступ без границ. Протокол VLESS+Reality "
+ "маскируется под обычный HTTPS-трафик, ничем не палится.\n\n"
+ f"{DIVIDER}\n"
+ f"Сайт: {SITE_DOMAIN}"
+)
+
+
+async def send_main_menu(message: Message):
+ await message.answer("Главное меню:", reply_markup=main_menu_kb(message.from_user.id))
+
+
+@dp.message(CommandStart(deep_link=True))
+async def start_deeplink(message: Message, command: CommandObject):
+ user = db.get_or_create_user(message.from_user.id, message.from_user.username)
+ payload = command.args or ""
+ if payload.startswith("gift_") or payload.startswith("gift-"):
+ code = payload[5:]
+ gift, err = db.redeem_gift_code(code, message.from_user.id)
+ if err == "not_found":
+ await message.answer("Такого подарочного кода не существует.")
+ return await send_main_menu(message)
+ if err == "already_used":
+ await message.answer("Этот код уже был использован.")
+ return await send_main_menu(message)
+ plan = PLANS_BY_CODE[gift["plan"]]
+ sub = db.create_subscription(message.from_user.id, gift["node"], plan["days"], plan["code"], source="gift", )
+ gift_node = db.get_node(gift["node"])
+ xray_manager.add_client_to_node(gift_node, sub["uuid"], email=sub["uuid"])
+ await message.answer(
+ f"Подарок активирован\n\n"
+ f"Сервер: {gift_node['label']}\n"
+ f"Срок: {plan['label']}\n\n"
+ f"{DIVIDER}\n"
+ f"Ссылка-подписка:\n{sub_url_for(user['token'])}",
+ reply_markup=connect_kb(user["token"]),
+ )
+ return await send_main_menu(message)
+ await send_main_menu(message)
+
+
+@dp.message(CommandStart())
+async def start_plain(message: Message):
+ db.get_or_create_user(message.from_user.id, message.from_user.username)
+ await message.answer(
+ "Привет! Это бот MBS Panel.\nВыбери действие ниже.",
+ )
+ await send_main_menu(message)
+
+
+@dp.callback_query(F.data == "menu:main")
+async def cb_menu_main(cb: CallbackQuery):
+ await cb.message.edit_text("Главное меню:", reply_markup=main_menu_kb(cb.from_user.id))
+ await cb.answer()
+
+
+@dp.callback_query(F.data == "menu:about")
+async def cb_about(cb: CallbackQuery):
+ kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Назад", callback_data="menu:main")]])
+ await cb.message.edit_text(ABOUT_TEXT, reply_markup=kb)
+ await cb.answer()
+
+
+@dp.callback_query(F.data == "menu:get")
+async def cb_get(cb: CallbackQuery):
+ await cb.message.edit_text("Выбери сервер:", reply_markup=nodes_kb("node"))
+ await cb.answer()
+
+
+@dp.callback_query(F.data.startswith("node:"))
+async def cb_node(cb: CallbackQuery):
+ node_code = cb.data.split(":")[1]
+ await cb.message.edit_text("Выбери срок:", reply_markup=plans_kb("plan", node_code))
+ await cb.answer()
+
+
+@dp.callback_query(F.data.startswith("plan:"))
+async def cb_plan(cb: CallbackQuery):
+ _, node_code, plan_code = cb.data.split(":")
+ plan = PLANS_BY_CODE[plan_code]
+ user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
+ sub = db.create_subscription(cb.from_user.id, node_code, plan["days"], plan_code, source="bot")
+ node_row = db.get_node(node_code)
+ xray_manager.add_client_to_node(node_row, sub["uuid"], email=sub["uuid"])
+ kb = connect_kb(user["token"], extra_rows=[
+ [InlineKeyboardButton(text="Моя подписка", callback_data="menu:mysub")],
+ [InlineKeyboardButton(text="В меню", callback_data="menu:main")],
+ ])
+ await cb.message.edit_text(
+ f"Подписка активна\n\n"
+ f"Сервер: {node_row['label']}\n"
+ f"Срок: {plan['label']} — до {sub['expires_at'][:10]}\n\n"
+ f"{DIVIDER}\n"
+ f"Ссылка-подписка:\n{sub_url_for(user['token'])}",
+ reply_markup=kb,
+ )
+ await cb.answer("Подписка выдана")
+
+
+@dp.callback_query(F.data == "menu:mysub")
+async def cb_mysub(cb: CallbackQuery):
+ user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
+ subs = db.list_active_subscriptions(tg_id=cb.from_user.id)
+ if not subs:
+ kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="В меню", callback_data="menu:main")]])
+ await cb.message.edit_text("У тебя пока нет активных подписок.", reply_markup=kb)
+ return await cb.answer()
+ lines = ["Твои подписки\n"]
+ for s in subs:
+ plan = PLANS_BY_CODE.get(s["plan"], {}).get("label", s["plan"])
+ node_info = db.get_node(s["node"])
+ node = node_info["label"] if node_info else s["node"]
+ lines.append(f"{node} — {plan}, до {s['expires_at'][:10]}")
+ lines.append(f"\n{DIVIDER}\nСсылка-подписка:\n{sub_url_for(user['token'])}")
+ kb = connect_kb(user["token"], extra_rows=[[InlineKeyboardButton(text="В меню", callback_data="menu:main")]])
+ await cb.message.edit_text("\n".join(lines), reply_markup=kb)
+ await cb.answer()
+
+
+def admin_menu_kb() -> InlineKeyboardMarkup:
+ return InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="Создать гифт-ссылку", callback_data="admin:gift")],
+ [InlineKeyboardButton(text="Статистика", callback_data="admin:stats")],
+ [InlineKeyboardButton(text="Синхронизировать xray", callback_data="admin:sync")],
+ [InlineKeyboardButton(text="В меню", callback_data="menu:main")],
+ ])
+
+
+@dp.callback_query(F.data == "menu:admin")
+async def cb_admin(cb: CallbackQuery):
+ if not is_admin(cb.from_user.id):
+ return await cb.answer("Нет доступа", show_alert=True)
+ await cb.message.edit_text("Админ-панель:", reply_markup=admin_menu_kb())
+ await cb.answer()
+
+
+@dp.callback_query(F.data == "admin:gift")
+async def cb_admin_gift(cb: CallbackQuery):
+ if not is_admin(cb.from_user.id):
+ return await cb.answer("Нет доступа", show_alert=True)
+ await cb.message.edit_text("Для какого сервера гифт?", reply_markup=nodes_kb("admin:giftnode"))
+ await cb.answer()
+
+
+@dp.callback_query(F.data.startswith("admin:giftnode:"))
+async def cb_admin_giftnode(cb: CallbackQuery):
+ if not is_admin(cb.from_user.id):
+ return await cb.answer("Нет доступа", show_alert=True)
+ node_code = cb.data.split(":")[2]
+ await cb.message.edit_text("На какой срок?", reply_markup=plans_kb("admin:giftmake", node_code))
+ await cb.answer()
+
+
+@dp.callback_query(F.data.startswith("admin:giftmake:"))
+async def cb_admin_giftmake(cb: CallbackQuery):
+ if not is_admin(cb.from_user.id):
+ return await cb.answer("Нет доступа", show_alert=True)
+ _, _, node_code, plan_code = cb.data.split(":")
+ code = db.create_gift_code(node_code, plan_code, cb.from_user.id)
+ global _bot_username
+ if _bot_username is None:
+ me = await bot.get_me()
+ _bot_username = me.username
+ link = f"https://t.me/{_bot_username}?start=gift_{code}"
+ plan = PLANS_BY_CODE[plan_code]
+ kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="В админку", callback_data="menu:admin")]])
+ await cb.message.edit_text(
+ f"Гифт-ссылка готова ({db.get_node(node_code)['label']}, {plan['label']}):\n\n"
+ f"{link}\n\nОткрывший её (даже впервые) сразу получит подписку.",
+ reply_markup=kb,
+ )
+ await cb.answer()
+
+
+@dp.callback_query(F.data == "admin:stats")
+async def cb_admin_stats(cb: CallbackQuery):
+ if not is_admin(cb.from_user.id):
+ return await cb.answer("Нет доступа", show_alert=True)
+ s = db.stats()
+ kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="В админку", callback_data="menu:admin")]])
+ await cb.message.edit_text(
+ f"Пользователей: {s['users']}\n"
+ f"Активных подписок: {s['active_subscriptions']}\n"
+ f"Всего подписок: {s['total_subscriptions']}\n"
+ f"Гифт-кодов создано: {s['gifts_created']} / использовано: {s['gifts_used']}",
+ reply_markup=kb,
+ )
+ await cb.answer()
+
+
+@dp.callback_query(F.data == "admin:sync")
+async def cb_admin_sync(cb: CallbackQuery):
+ if not is_admin(cb.from_user.id):
+ return await cb.answer("Нет доступа", show_alert=True)
+ result = xray_manager.sync_all()
+ kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="В админку", callback_data="menu:admin")]])
+ await cb.message.edit_text(
+ f"Синхронизация xray выполнена.\nАктивно клиентов: {result['active_now']}\n"
+ f"Убрано истёкших: {result['removed_expired']}\nБыл перезапуск: {'да' if result['reloaded'] else 'нет'}",
+ reply_markup=kb,
+ )
+ await cb.answer()
+
+
+async def periodic_sync():
+ while True:
+ try:
+ xray_manager.sync_all()
+ except Exception:
+ log.exception("periodic sync failed")
+ await asyncio.sleep(90)
+
+
+async def main():
+ global _bot_username
+ me = await bot.get_me()
+ _bot_username = me.username
+ log.info("Bot started as @%s", _bot_username)
+ asyncio.create_task(periodic_sync())
+ await dp.start_polling(bot)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())