2026-09-10 17:45:43 +05:00
|
|
|
|
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
|
2026-09-10 21:44:45 +05:00
|
|
|
|
import payments
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
import settings
|
feat: outbound webhooks for payment/subscription events
Per the docs.rw comparison researched earlier tonight, Remnawave
fires webhooks for users+nodes and Marzban for users — this panel
had neither, only received inbound webhooks from payment providers.
New webhooks.py, fired on payment.paid (both webhook-driven and
reconciler-driven grant paths, so it fires regardless of which one
actually processes a given payment) and
subscription.granted_by_admin (kept as a distinct event name rather
than reusing payment.paid, since no money necessarily changed hands
there). Settings tab gets a URL field; a secret is generated once on
first save via secrets.token_hex and never regenerated on later URL
edits, so a receiver's signature verification doesn't silently break
when the admin just updates the endpoint. Every delivery is
HMAC-SHA256 signed over the raw JSON body via X-Signature, same
verification shape Platega already uses for its inbound webhooks.
Delivery is fire-and-forget (10s timeout, swallows all exceptions) —
a receiver being down must never block or fail a payment grant.
Reads WEBHOOK_URL/WEBHOOK_SECRET fresh from .env via legal.py's
existing reader instead of adding a third copy of that logic.
Verified with a real local HTTP server: actual delivery, payload
shape, and that the received X-Signature verifies against the
configured secret using the receiver's own side of the HMAC — not
just asserting the sender computed *something*. Also verified the
no-URL-configured no-op path and that changing the URL later does not
rotate the secret.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:15:13 +05:00
|
|
|
|
import webhooks
|
2026-09-10 17:45:43 +05:00
|
|
|
|
import xray_manager
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
from config import BOT_TOKEN, ADMIN_IDS, SUB_DOMAIN, SITE_DOMAIN
|
2026-09-10 17:45:43 +05:00
|
|
|
|
|
|
|
|
|
|
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:
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
payments_enabled = settings.get_payment_settings()["payments_enabled"]
|
2026-09-10 17:45:43 +05:00
|
|
|
|
rows = []
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
for p in settings.get_plans():
|
|
|
|
|
|
label = f"{p['label']} — {p['price']} ₽" if payments_enabled and p["price"] > 0 else p["label"]
|
2026-09-10 21:44:45 +05:00
|
|
|
|
rows.append([InlineKeyboardButton(text=label, callback_data=f"{prefix}:{node_code}:{p['code']}")])
|
2026-09-10 17:45:43 +05:00
|
|
|
|
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)
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plan = settings.get_plans_by_code().get(gift["plan"])
|
2026-09-10 17:45:43 +05:00
|
|
|
|
gift_node = db.get_node(gift["node"])
|
2026-09-11 22:20:54 +05:00
|
|
|
|
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"])
|
2026-09-10 17:45:43 +05:00
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 21:44:45 +05:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 17:45:43 +05:00
|
|
|
|
@dp.callback_query(F.data.startswith("plan:"))
|
|
|
|
|
|
async def cb_plan(cb: CallbackQuery):
|
|
|
|
|
|
_, node_code, plan_code = cb.data.split(":")
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plan = settings.get_plans_by_code()[plan_code]
|
2026-09-10 21:44:45 +05:00
|
|
|
|
db.get_or_create_user(cb.from_user.id, cb.from_user.username)
|
|
|
|
|
|
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
if settings.get_payment_settings()["payments_enabled"] and plan["price"] > 0 and payments.available_providers():
|
2026-09-10 21:44:45 +05:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-09-10 17:45:43 +05:00
|
|
|
|
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)
|
2026-09-11 22:20:54 +05:00
|
|
|
|
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
|
2026-09-10 17:45:43 +05:00
|
|
|
|
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("Подписка выдана")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 21:44:45 +05:00
|
|
|
|
@dp.callback_query(F.data.startswith("pay:"))
|
|
|
|
|
|
async def cb_pay(cb: CallbackQuery):
|
|
|
|
|
|
_, provider, node_code, plan_code = cb.data.split(":")
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plan = settings.get_plans_by_code()[plan_code]
|
2026-09-10 21:44:45 +05:00
|
|
|
|
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"MBS Panel — {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()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 17:45:43 +05:00
|
|
|
|
@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"]
|
2026-09-13 22:10:12 +05:00
|
|
|
|
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plans_by_code = settings.get_plans_by_code()
|
2026-09-10 17:45:43 +05:00
|
|
|
|
for s in subs:
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plan = plans_by_code.get(s["plan"], {}).get("label", s["plan"])
|
2026-09-13 22:10:12 +05:00
|
|
|
|
node_info = nodes_by_code.get(s["node"])
|
2026-09-10 17:45:43 +05:00
|
|
|
|
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}"
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plan = settings.get_plans_by_code()[plan_code]
|
2026-09-10 17:45:43 +05:00
|
|
|
|
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)
|
2026-09-11 22:20:54 +05:00
|
|
|
|
result = await asyncio.to_thread(xray_manager.sync_all)
|
2026-09-10 17:45:43 +05:00
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 22:46:55 +05:00
|
|
|
|
async def reconcile_pending_payments():
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
if not settings.get_payment_settings()["payments_enabled"]:
|
2026-09-10 22:46:55 +05:00
|
|
|
|
return
|
2026-09-13 22:10:12 +05:00
|
|
|
|
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plans_by_code = settings.get_plans_by_code()
|
2026-09-10 22:46:55 +05:00
|
|
|
|
for payment in db.list_payments():
|
|
|
|
|
|
if payment["status"] != "pending" or not payment.get("external_id"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
2026-09-11 22:20:54 +05:00
|
|
|
|
status = await asyncio.to_thread(payments.check_payment_status, payment["provider"], payment["external_id"])
|
2026-09-10 22:46:55 +05:00
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if status in payments.PAID_STATUSES:
|
feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.
New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).
api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.
New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.
admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.
Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00
|
|
|
|
plan = plans_by_code.get(payment["plan"])
|
2026-09-13 22:10:12 +05:00
|
|
|
|
node_row = nodes_by_code.get(payment["node"])
|
2026-09-10 22:46:55 +05:00
|
|
|
|
if not plan or not node_row:
|
|
|
|
|
|
continue
|
2026-09-11 22:20:54 +05:00
|
|
|
|
granted = db.mark_payment_paid(payment["id"])
|
|
|
|
|
|
if not granted:
|
|
|
|
|
|
continue
|
2026-09-10 22:46:55 +05:00
|
|
|
|
sub = db.create_subscription(payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment")
|
2026-09-11 22:20:54 +05:00
|
|
|
|
await asyncio.to_thread(xray_manager.add_client_to_node, node_row, sub["uuid"], email=sub["uuid"])
|
2026-09-10 22:46:55 +05:00
|
|
|
|
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")
|
feat: outbound webhooks for payment/subscription events
Per the docs.rw comparison researched earlier tonight, Remnawave
fires webhooks for users+nodes and Marzban for users — this panel
had neither, only received inbound webhooks from payment providers.
New webhooks.py, fired on payment.paid (both webhook-driven and
reconciler-driven grant paths, so it fires regardless of which one
actually processes a given payment) and
subscription.granted_by_admin (kept as a distinct event name rather
than reusing payment.paid, since no money necessarily changed hands
there). Settings tab gets a URL field; a secret is generated once on
first save via secrets.token_hex and never regenerated on later URL
edits, so a receiver's signature verification doesn't silently break
when the admin just updates the endpoint. Every delivery is
HMAC-SHA256 signed over the raw JSON body via X-Signature, same
verification shape Platega already uses for its inbound webhooks.
Delivery is fire-and-forget (10s timeout, swallows all exceptions) —
a receiver being down must never block or fail a payment grant.
Reads WEBHOOK_URL/WEBHOOK_SECRET fresh from .env via legal.py's
existing reader instead of adding a third copy of that logic.
Verified with a real local HTTP server: actual delivery, payload
shape, and that the received X-Signature verifies against the
configured secret using the receiver's own side of the HMAC — not
just asserting the sender computed *something*. Also verified the
no-URL-configured no-op path and that changing the URL later does not
rotate the secret.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:15:13 +05:00
|
|
|
|
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"],
|
|
|
|
|
|
})
|
2026-09-10 22:46:55 +05:00
|
|
|
|
elif status in payments.FAILED_STATUSES:
|
|
|
|
|
|
db.mark_payment_failed(payment["id"])
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 17:45:43 +05:00
|
|
|
|
async def periodic_sync():
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
2026-09-11 22:20:54 +05:00
|
|
|
|
await asyncio.to_thread(xray_manager.sync_all)
|
2026-09-10 17:45:43 +05:00
|
|
|
|
except Exception:
|
|
|
|
|
|
log.exception("periodic sync failed")
|
2026-09-10 22:46:55 +05:00
|
|
|
|
try:
|
|
|
|
|
|
await reconcile_pending_payments()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
log.exception("payment reconciliation failed")
|
2026-09-12 15:08:34 +05:00
|
|
|
|
try:
|
|
|
|
|
|
db.delete_expired_admin_sessions()
|
2026-09-12 15:43:52 +05:00
|
|
|
|
db.delete_expired_pending_totp()
|
security: rate-limit admin login and TOTP verification
Neither endpoint had any brute-force protection — TOTP codes are only
6 digits (1M combinations) and HMAC-SHA1 verification is cheap, so an
unthrottled /admin/api/login/totp is a realistic brute-force target
within a pending token's 5-minute window. Password login had the same
gap.
DB-backed (new login_attempts table), not in-memory — this matters
now that mbs-api runs multiple worker processes (see 11c75c1): an
in-process counter would let an attacker split requests across
workers and bypass it entirely, same class of mistake as an
unsynchronized in-memory cache. Keyed by client IP (nginx already
sets X-Real-IP on every proxied request, install.sh has always done
this).
10 failed attempts / 15min for password, 10 / 5min for TOTP codes,
counted per-IP per-kind. Successful login clears that IP's recent
failures. Old rows pruned in the existing 90s periodic_sync cleanup
alongside sessions and pending_totp.
Verified: threshold counting, per-IP isolation, per-kind isolation
(password vs totp tracked separately), clear-on-success, and the
age-based cleanup only removing rows older than the cutoff.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:42:20 +05:00
|
|
|
|
db.delete_old_login_attempts()
|
2026-09-12 15:08:34 +05:00
|
|
|
|
except Exception:
|
|
|
|
|
|
log.exception("expired admin session cleanup failed")
|
2026-09-10 17:45:43 +05:00
|
|
|
|
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())
|