bot + api: telegram bot (aiogram) and FastAPI backend
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
e4c2012cf8
commit
073c4fb9f0
2 changed files with 844 additions and 0 deletions
549
api.py
Normal file
549
api.py
Normal file
|
|
@ -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 = """<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MBS Panel — подписка</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
|
||||
--text: #eceef2; --muted: #868c99; --accent: #7c6cf0;
|
||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
padding: 24px;
|
||||
}}
|
||||
.card {{
|
||||
max-width: 400px; width: 100%; background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 16px; padding: 32px 28px; text-align: center;
|
||||
opacity: 0; transform: translateY(10px); filter: blur(6px);
|
||||
animation: enter 0.6s var(--ease) forwards;
|
||||
}}
|
||||
@keyframes enter {{ to {{ opacity: 1; transform: translateY(0); filter: blur(0); }} }}
|
||||
.badge {{
|
||||
font-size: 12px; color: var(--muted); letter-spacing: 0.06em;
|
||||
text-transform: uppercase; margin-bottom: 10px;
|
||||
}}
|
||||
h1 {{ font-size: 20px; margin: 0 0 6px; font-weight: 600; letter-spacing: -0.01em; }}
|
||||
p.sub {{ color: var(--muted); font-size: 14px; margin: 0 0 26px; line-height: 1.5; }}
|
||||
.btn {{
|
||||
display: block; width: 100%; padding: 14px 18px; border-radius: 10px;
|
||||
background: var(--text); color: var(--bg); text-decoration: none;
|
||||
font-weight: 600; font-size: 15px; margin-bottom: 10px; border: none; cursor: pointer;
|
||||
transition: transform 0.15s var(--ease), opacity 0.15s var(--ease);
|
||||
}}
|
||||
.btn:hover {{ opacity: 0.85; }}
|
||||
.btn:active {{ transform: scale(0.97); }}
|
||||
.btn.secondary {{ background: transparent; color: var(--text); border: 1px solid var(--border); }}
|
||||
.btn.secondary:hover {{ opacity: 1; border-color: #333947; }}
|
||||
.link-box {{
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 12px;
|
||||
font-size: 12px; color: var(--muted); word-break: break-all; margin-bottom: 22px; text-align: left;
|
||||
opacity: 0; animation: fadeIn 0.5s var(--ease) 0.2s forwards;
|
||||
}}
|
||||
@keyframes fadeIn {{ to {{ opacity: 1; }} }}
|
||||
.thanks {{ font-size: 13px; color: var(--muted); line-height: 1.5; }}
|
||||
.qr-box {{
|
||||
display: flex; justify-content: center; margin-bottom: 22px;
|
||||
opacity: 0; animation: fadeIn 0.5s var(--ease) 0.3s forwards;
|
||||
}}
|
||||
.qr-box img, .qr-box canvas {{ border-radius: 10px; background: #fff; padding: 8px; }}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {{
|
||||
*, *::before, *::after {{ animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }}
|
||||
.card, .link-box, .qr-box {{ opacity: 1 !important; transform: none !important; filter: none !important; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="badge">MBS Panel</div>
|
||||
<h1>Подписка готова</h1>
|
||||
<p class="sub">Нажми кнопку — сервер добавится в Happ автоматически, или отсканируй QR другим устройством</p>
|
||||
<a class="btn" href="happ://add/{sub_url}">Добавить в Happ</a>
|
||||
<a class="btn secondary" href="{sub_url}">Открыть ссылку подписки</a>
|
||||
<div class="qr-box" id="qr"></div>
|
||||
<div class="link-box">{sub_url}</div>
|
||||
<div class="thanks">Спасибо, что пользуетесь MBS Panel.<br>Если Happ не установлен — скачай его в App Store или Google Play.</div>
|
||||
</div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<script>
|
||||
new QRCode(document.getElementById("qr"), {{
|
||||
text: "{sub_url}", width: 160, height: 160,
|
||||
colorDark: "#0a0b0f", colorLight: "#ffffff", correctLevel: QRCode.CorrectLevel.M,
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
SUB_PAGE_EXPIRED_TEMPLATE = """<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MBS Panel — подписка</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #0a0b0f; --card: #131519; --border: #1e2128;
|
||||
--text: #eceef2; --muted: #868c99; --accent: #7c6cf0; --red: #e5686b;
|
||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased; padding: 24px;
|
||||
}}
|
||||
.card {{
|
||||
max-width: 400px; width: 100%; background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 16px; padding: 32px 28px; text-align: center;
|
||||
opacity: 0; transform: translateY(10px); filter: blur(6px);
|
||||
animation: enter 0.6s var(--ease) forwards;
|
||||
}}
|
||||
@keyframes enter {{ to {{ opacity: 1; transform: translateY(0); filter: blur(0); }} }}
|
||||
.badge {{ font-size: 12px; color: var(--muted); letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 10px; }}
|
||||
h1 {{ font-size: 20px; margin: 0 0 6px; font-weight: 600; letter-spacing: -0.01em; color: var(--red); }}
|
||||
p.sub {{ color: var(--muted); font-size: 14px; margin: 0 0 26px; line-height: 1.5; }}
|
||||
.btn {{
|
||||
display: block; width: 100%; padding: 14px 18px; border-radius: 10px;
|
||||
background: var(--text); color: var(--bg); text-decoration: none;
|
||||
font-weight: 600; font-size: 15px; border: none; cursor: pointer;
|
||||
transition: transform 0.15s var(--ease), opacity 0.15s var(--ease);
|
||||
}}
|
||||
.btn:hover {{ opacity: 0.85; }}
|
||||
.btn:active {{ transform: scale(0.97); }}
|
||||
@media (prefers-reduced-motion: reduce) {{
|
||||
*, *::before, *::after {{ animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }}
|
||||
.card {{ opacity: 1 !important; transform: none !important; filter: none !important; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="badge">MBS Panel</div>
|
||||
<h1>Подписка истекла</h1>
|
||||
<p class="sub">Доступ по этой ссылке закончился. Продли подписку в боте — ссылка останется той же, ничего заново настраивать не нужно.</p>
|
||||
<a class="btn" href="https://t.me/{bot_username}" target="_blank">Продлить в боте</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@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)
|
||||
295
bot.py
Normal file
295
bot.py
Normal file
|
|
@ -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 = (
|
||||
"<b>MBS Panel</b>\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"<b>Подарок активирован</b>\n\n"
|
||||
f"Сервер: {gift_node['label']}\n"
|
||||
f"Срок: {plan['label']}\n\n"
|
||||
f"{DIVIDER}\n"
|
||||
f"Ссылка-подписка:\n<code>{sub_url_for(user['token'])}</code>",
|
||||
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"<b>Подписка активна</b>\n\n"
|
||||
f"Сервер: {node_row['label']}\n"
|
||||
f"Срок: {plan['label']} — до {sub['expires_at'][:10]}\n\n"
|
||||
f"{DIVIDER}\n"
|
||||
f"Ссылка-подписка:\n<code>{sub_url_for(user['token'])}</code>",
|
||||
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 = ["<b>Твои подписки</b>\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<code>{sub_url_for(user['token'])}</code>")
|
||||
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"<code>{link}</code>\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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue