From 073c4fb9f05a4e35d480dd245fd32116021970ba Mon Sep 17 00:00:00 2001 From: savsis Date: Thu, 10 Sep 2026 17:45:43 +0500 Subject: [PATCH] bot + api: telegram bot (aiogram) and FastAPI backend Co-Authored-By: Claude Sonnet 5 --- api.py | 549 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ bot.py | 295 +++++++++++++++++++++++++++++++ 2 files changed, 844 insertions(+) create mode 100644 api.py create mode 100644 bot.py 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 + Открыть ссылку подписки +
+ +
Спасибо, что пользуетесь 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())