mbs-panel/bot.py
savsis 7cc50973f6 feat: custom brand name everywhere + a working client-facing site out of the box
User ask, paraphrased: install it, get help wiring up payments, and
immediately have a ready site under your own name — not "MBS Panel"
plastered everywhere and a bunch of manual follow-up.

Two things were actually broken/missing, found by tracing every surface
a real customer or the operator would see:

1. "MBS Panel" was hardcoded in ~20 places (bot messages, subscription
   page, admin panel splash/title/sidebar, legal pages, 2FA issuer,
   install.sh) with zero way to change it short of editing source.
   New BRAND_NAME config value (config.py default "MBS Panel", so this
   is 100% backward compatible for existing installs) wired through
   everywhere via the same live-settings pattern from the last commit
   (settings.get_brand_name(), no restart needed anywhere it's used).
   New Настройки → «Название» section in the admin panel to change it.

2. site/index.html and site/cabinet.html — a fully-built landing page +
   personal-cabinet template, already in the repo — were never actually
   served by anything. Not mounted by FastAPI, not deployed by
   install.sh, not linked from anywhere. Pure dead weight: a repo that
   looked like it shipped a client site but didn't. Now legal.py gets a
   render_site_page() (same {{TOKEN}} substitution + HTML-escaping as
   the existing offer/privacy renderer, new tokens: BRAND_NAME,
   SITE_DOMAIN, SUB_DOMAIN, BOT_USERNAME) and GET "/" serves the branded
   landing page on any host that isn't PANEL_DOMAIN (in practice:
   SUB_DOMAIN, which nginx already routes to this backend — zero
   install.sh/nginx/certbot changes needed, so this is live on every
   existing install without an upgrade step beyond `mbs update`).
   GET /cabinet.html serves the cabinet. Landing page's pricing section
   now fetches real, live prices from a new public GET /api/plans
   instead of showing static duration labels with no numbers.

Also fixed along the way, same staleness-bug class as the payments/HWID
fix last commit, found by grepping for every remaining frozen `from
config import ...` in api.py: BOT_TOKEN/BOT_USERNAME were still frozen
constants in api.py (mbs-api never restarts itself). Concretely this
meant: changing the bot via Настройки → Telegram-бот would leave
_tg_send_message (payment-received notifications) silently trying the
OLD token, admin_get_bot_settings showing the OLD username right after
a successful save, and gift-code links pointing at the OLD bot — all
until a manual mbs restart, same shape as the Platega-secret bug fixed
last commit. Added settings.bot_credentials(), wired it through every
call site (hoisted out of loops where relevant, same N+1 discipline as
always), removed the now-stale "выполни mbs restart" copy from the bot
settings hint.

legal.py's own BOT_USERNAME import was frozen too (used by the /offer
and /privacy {{BOT_USERNAME}} token) — switched to reading it live
in-module (no settings.py import from legal.py, would've been circular
since settings.py already imports legal.py for the env reader).

install.sh: new interactive prompt for the brand name (default "MBS
Panel", so hitting enter reproduces today's behavior exactly), written
to .env, echoed in the final summary along with the now-live site URL.

Verification: same story as always — api.py/bot.py still can't import
locally (no pydantic-core wheel for Python 3.14 on this machine).
py_compile + pyflakes clean across the whole repo. Real runtime test
against an isolated .env fixture: brand name and bot-credential live
reads (no reimport), render_site_page() token substitution correctness
on the actual site/index.html and site/cabinet.html files including an
XSS check (brand name containing <script> comes out HTML-escaped), and
a regression check that adding the BRAND_NAME token to the existing
legal.render() didn't break offer.html/privacy.html. Extracted
SUB_PAGE_TEMPLATE/SUB_PAGE_EXPIRED_TEMPLATE via ast from api.py (can't
import the module, but can pull the string constants) and ran the real
.format() calls against them to catch any brace-escaping mistake in the
new {brand_name} placeholder — CSS braces in those templates are
already double-escaped for .format(), easy to get wrong. Extracted and
node --check'd admin.html's whole inline script, div-tag-balance check
on the full file. install.sh's new prompt+heredoc snippet run standalone
with piped stdin (both a brand name with spaces and an empty/default
input), round-tripped the resulting .env back through the real
env-parsing logic. Extended the existing CI "app wiring" step (which
does import api/bot for real on Linux) with branding assertions calling
the actual route functions directly (api.root(), api.public_plans(),
api.public_branding()) — ran every part of that step's new logic that
doesn't need api.py locally first, to catch what's catchable before
trusting the rest to CI once the account's abuse-review lifts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:52:54 +05:00

405 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 payments
import settings
import webhooks
import xray_manager
from config import BOT_TOKEN, ADMIN_IDS, 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:
payments_enabled = settings.get_payment_settings()["payments_enabled"]
rows = []
for p in settings.get_plans():
label = f"{p['label']} — {p['price']} ₽" if payments_enabled and p["price"] > 0 else p["label"]
rows.append([InlineKeyboardButton(text=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)
def about_text() -> str:
return (
f"<b>{settings.get_brand_name()}</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 = settings.get_plans_by_code().get(gift["plan"])
gift_node = db.get_node(gift["node"])
if not plan or not gift_node:
await message.answer("Этот подарок больше недоступен.")
return await send_main_menu(message)
sub = db.create_subscription(message.from_user.id, gift["node"], plan["days"], plan["code"], source="gift", )
await asyncio.to_thread(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(
f"Привет! Это бот {settings.get_brand_name()}.\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()
def providers_kb(node_code: str, plan_code: str) -> InlineKeyboardMarkup:
rows = []
for p in payments.available_providers():
rows.append([InlineKeyboardButton(text=payments.PROVIDER_NAMES[p], callback_data=f"pay:{p}:{node_code}:{plan_code}")])
rows.append([InlineKeyboardButton(text="Назад", callback_data=f"node:{node_code}")])
return InlineKeyboardMarkup(inline_keyboard=rows)
@dp.callback_query(F.data.startswith("plan:"))
async def cb_plan(cb: CallbackQuery):
_, node_code, plan_code = cb.data.split(":")
plan = settings.get_plans_by_code()[plan_code]
db.get_or_create_user(cb.from_user.id, cb.from_user.username)
if settings.get_payment_settings()["payments_enabled"] and plan["price"] > 0 and payments.available_providers():
await cb.message.edit_text(
f"<b>{plan['label']}</b> — {plan['price']} ₽\n\nВыбери способ оплаты:",
reply_markup=providers_kb(node_code, plan_code),
)
return await cb.answer()
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)
await asyncio.to_thread(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.startswith("pay:"))
async def cb_pay(cb: CallbackQuery):
_, provider, node_code, plan_code = cb.data.split(":")
plan = settings.get_plans_by_code()[plan_code]
node_row = db.get_node(node_code)
payment_id = payments.new_payment_id()
db.create_payment(payment_id, cb.from_user.id, node_code, plan_code, provider, plan["price"])
try:
external_id, pay_url = payments.create_payment_link(
provider, payment_id, plan["price"], f"{settings.get_brand_name()} — {node_row['label']}, {plan['label']}",
)
except Exception:
log.exception("payment creation failed")
db.mark_payment_failed(payment_id)
return await cb.answer("Не получилось создать платёж, попробуй позже", show_alert=True)
db.set_payment_external(payment_id, external_id, pay_url)
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="Оплатить", url=pay_url)],
[InlineKeyboardButton(text="Назад", callback_data=f"plan:{node_code}:{plan_code}")],
])
await cb.message.edit_text(
f"Счёт на {plan['price']} ₽ создан.\nПосле оплаты подписка выдастся автоматически.",
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"]
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
plans_by_code = settings.get_plans_by_code()
for s in subs:
plan = plans_by_code.get(s["plan"], {}).get("label", s["plan"])
node_info = nodes_by_code.get(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 = settings.get_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 = await asyncio.to_thread(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 reconcile_pending_payments():
if not settings.get_payment_settings()["payments_enabled"]:
return
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
plans_by_code = settings.get_plans_by_code()
for payment in db.list_payments():
if payment["status"] != "pending" or not payment.get("external_id"):
continue
try:
status = await asyncio.to_thread(payments.check_payment_status, payment["provider"], payment["external_id"])
except Exception:
continue
if status in payments.PAID_STATUSES:
plan = plans_by_code.get(payment["plan"])
node_row = nodes_by_code.get(payment["node"])
if not plan or not node_row:
continue
granted = db.mark_payment_paid(payment["id"])
if not granted:
continue
sub = db.create_subscription(payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment")
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
user = db.get_or_create_user(payment["tg_id"], None)
try:
await bot.send_message(
payment["tg_id"],
f"<b>Оплата получена</b>\n\n"
f"Сервер: {node_row['label']}\n"
f"Срок: {plan['label']} — до {sub['expires_at'][:10]}\n\n"
f"Ссылка-подписка:\n{sub_url_for(user['token'])}",
)
except Exception:
log.exception("failed to notify user about payment")
await asyncio.to_thread(webhooks.send, "payment.paid", {
"tg_id": payment["tg_id"],
"amount": payment["amount"],
"provider": payment["provider"],
"node": payment["node"],
"plan": payment["plan"],
"subscription_uuid": sub["uuid"],
"expires_at": sub["expires_at"],
})
elif status in payments.FAILED_STATUSES:
db.mark_payment_failed(payment["id"])
async def periodic_sync():
while True:
try:
await asyncio.to_thread(xray_manager.sync_all)
except Exception:
log.exception("periodic sync failed")
try:
await reconcile_pending_payments()
except Exception:
log.exception("payment reconciliation failed")
try:
db.delete_expired_admin_sessions()
db.delete_expired_pending_totp()
db.delete_old_login_attempts()
except Exception:
log.exception("expired admin session cleanup 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())