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")],
|
feat: two-sided referral program
Each user gets a short ref_code (backfilled lazily for pre-existing
accounts too) and a shareable t.me/<bot>?start=ref_<code> link, new
"Пригласить друга" menu item shows it plus how many referrals actually
converted and any bonus days waiting to be applied.
Reward fires once, on the referred user's first subscription of any
kind (free, gift, or paid) — not on signup, so an unconverted click
never pays out. Both sides get REFERRAL_BONUS_DAYS (config.py/.env,
default 3): the referrer's day count comes from settings.py's live-read
pattern, same as prices/HWID, so it's tunable without a restart even
before a panel UI exists for it. Bonus extends an active subscription
directly if the recipient has one, otherwise accumulates in
bonus_days_pending and gets folded into whichever subscription they
create next (redeemed automatically inside create_subscription, one
choke point regardless of which of bot.py's several call sites created
it — free trial, gift code, paid, or admin grant).
Guards: no self-referral, referrer must exist, first-touch attribution
only (a second ?start=ref_ link never overwrites it), and only takes
for genuinely new accounts (no existing subscriptions) — attaching a
referrer to an already-active user was never the intent.
Tested two ways, matching this repo's usual db.py-can-be-imported-
standalone / bot.py-needs-a-workaround split: 16 checks against a real
isolated sqlite db for the db.py logic (attribution, both reward paths,
double-reward guard, pending-bonus fold-in), then 9 more through an
actual `import bot` — aiogram/fastapi now have Python 3.14 wheels so
this imported for real rather than needing AST-extraction, modulo one
old blocker (xray_manager still imports the Unix-only fcntl for its
file lock) worked around with a tiny fake fcntl module in sys.modules,
same spirit as the fcntl shim already used elsewhere in this project's
history. Real start_deeplink and cb_referral calls, get_me() mocked to
avoid a live Telegram API call.
Not done: admin-panel UI toggle for REFERRAL_ENABLED/REFERRAL_BONUS_DAYS
(currently .env-only, like several other business tunables were before
they got a settings-page treatment) and a docs-tab writeup — happy to
add both if wanted, scoped this pass to the mechanic itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 22:29:15 +05:00
|
|
|
|
[InlineKeyboardButton(text="Пригласить друга", callback_data="menu:referral")],
|
2026-09-10 17:45:43 +05:00
|
|
|
|
[InlineKeyboardButton(text="О сервисе", callback_data="menu:about")],
|
|
|
|
|
|
]
|
|
|
|
|
|
if is_admin(tg_id):
|
|
|
|
|
|
rows.append([InlineKeyboardButton(text="Админка", callback_data="menu:admin")])
|
|
|
|
|
|
return InlineKeyboardMarkup(inline_keyboard=rows)
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: two-sided referral program
Each user gets a short ref_code (backfilled lazily for pre-existing
accounts too) and a shareable t.me/<bot>?start=ref_<code> link, new
"Пригласить друга" menu item shows it plus how many referrals actually
converted and any bonus days waiting to be applied.
Reward fires once, on the referred user's first subscription of any
kind (free, gift, or paid) — not on signup, so an unconverted click
never pays out. Both sides get REFERRAL_BONUS_DAYS (config.py/.env,
default 3): the referrer's day count comes from settings.py's live-read
pattern, same as prices/HWID, so it's tunable without a restart even
before a panel UI exists for it. Bonus extends an active subscription
directly if the recipient has one, otherwise accumulates in
bonus_days_pending and gets folded into whichever subscription they
create next (redeemed automatically inside create_subscription, one
choke point regardless of which of bot.py's several call sites created
it — free trial, gift code, paid, or admin grant).
Guards: no self-referral, referrer must exist, first-touch attribution
only (a second ?start=ref_ link never overwrites it), and only takes
for genuinely new accounts (no existing subscriptions) — attaching a
referrer to an already-active user was never the intent.
Tested two ways, matching this repo's usual db.py-can-be-imported-
standalone / bot.py-needs-a-workaround split: 16 checks against a real
isolated sqlite db for the db.py logic (attribution, both reward paths,
double-reward guard, pending-bonus fold-in), then 9 more through an
actual `import bot` — aiogram/fastapi now have Python 3.14 wheels so
this imported for real rather than needing AST-extraction, modulo one
old blocker (xray_manager still imports the Unix-only fcntl for its
file lock) worked around with a tiny fake fcntl module in sys.modules,
same spirit as the fcntl shim already used elsewhere in this project's
history. Real start_deeplink and cb_referral calls, get_me() mocked to
avoid a live Telegram API call.
Not done: admin-panel UI toggle for REFERRAL_ENABLED/REFERRAL_BONUS_DAYS
(currently .env-only, like several other business tunables were before
they got a settings-page treatment) and a docs-tab writeup — happy to
add both if wanted, scoped this pass to the mechanic itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 22:29:15 +05:00
|
|
|
|
async def get_bot_username() -> str:
|
|
|
|
|
|
global _bot_username
|
|
|
|
|
|
if _bot_username is None:
|
|
|
|
|
|
me = await bot.get_me()
|
|
|
|
|
|
_bot_username = me.username
|
|
|
|
|
|
return _bot_username
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 17:45:43 +05:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
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}"
|
|
|
|
|
|
)
|
2026-09-10 17:45:43 +05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 ""
|
feat: two-sided referral program
Each user gets a short ref_code (backfilled lazily for pre-existing
accounts too) and a shareable t.me/<bot>?start=ref_<code> link, new
"Пригласить друга" menu item shows it plus how many referrals actually
converted and any bonus days waiting to be applied.
Reward fires once, on the referred user's first subscription of any
kind (free, gift, or paid) — not on signup, so an unconverted click
never pays out. Both sides get REFERRAL_BONUS_DAYS (config.py/.env,
default 3): the referrer's day count comes from settings.py's live-read
pattern, same as prices/HWID, so it's tunable without a restart even
before a panel UI exists for it. Bonus extends an active subscription
directly if the recipient has one, otherwise accumulates in
bonus_days_pending and gets folded into whichever subscription they
create next (redeemed automatically inside create_subscription, one
choke point regardless of which of bot.py's several call sites created
it — free trial, gift code, paid, or admin grant).
Guards: no self-referral, referrer must exist, first-touch attribution
only (a second ?start=ref_ link never overwrites it), and only takes
for genuinely new accounts (no existing subscriptions) — attaching a
referrer to an already-active user was never the intent.
Tested two ways, matching this repo's usual db.py-can-be-imported-
standalone / bot.py-needs-a-workaround split: 16 checks against a real
isolated sqlite db for the db.py logic (attribution, both reward paths,
double-reward guard, pending-bonus fold-in), then 9 more through an
actual `import bot` — aiogram/fastapi now have Python 3.14 wheels so
this imported for real rather than needing AST-extraction, modulo one
old blocker (xray_manager still imports the Unix-only fcntl for its
file lock) worked around with a tiny fake fcntl module in sys.modules,
same spirit as the fcntl shim already used elsewhere in this project's
history. Real start_deeplink and cb_referral calls, get_me() mocked to
avoid a live Telegram API call.
Not done: admin-panel UI toggle for REFERRAL_ENABLED/REFERRAL_BONUS_DAYS
(currently .env-only, like several other business tunables were before
they got a settings-page treatment) and a docs-tab writeup — happy to
add both if wanted, scoped this pass to the mechanic itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 22:29:15 +05:00
|
|
|
|
if payload.startswith("ref_") or payload.startswith("ref-"):
|
|
|
|
|
|
ref_code = payload[4:]
|
|
|
|
|
|
referrer = db.get_user_by_ref_code(ref_code)
|
|
|
|
|
|
if referrer and settings.get_referral_settings()["enabled"]:
|
|
|
|
|
|
db.set_referred_by(message.from_user.id, referrer["tg_id"])
|
|
|
|
|
|
return await send_main_menu(message)
|
2026-09-10 17:45:43 +05:00
|
|
|
|
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(
|
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
|
|
|
|
f"Привет! Это бот {settings.get_brand_name()}.\nВыбери действие ниже.",
|
2026-09-10 17:45:43 +05:00
|
|
|
|
)
|
|
|
|
|
|
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")]])
|
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
|
|
|
|
await cb.message.edit_text(about_text(), reply_markup=kb)
|
2026-09-10 17:45:43 +05:00
|
|
|
|
await cb.answer()
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: two-sided referral program
Each user gets a short ref_code (backfilled lazily for pre-existing
accounts too) and a shareable t.me/<bot>?start=ref_<code> link, new
"Пригласить друга" menu item shows it plus how many referrals actually
converted and any bonus days waiting to be applied.
Reward fires once, on the referred user's first subscription of any
kind (free, gift, or paid) — not on signup, so an unconverted click
never pays out. Both sides get REFERRAL_BONUS_DAYS (config.py/.env,
default 3): the referrer's day count comes from settings.py's live-read
pattern, same as prices/HWID, so it's tunable without a restart even
before a panel UI exists for it. Bonus extends an active subscription
directly if the recipient has one, otherwise accumulates in
bonus_days_pending and gets folded into whichever subscription they
create next (redeemed automatically inside create_subscription, one
choke point regardless of which of bot.py's several call sites created
it — free trial, gift code, paid, or admin grant).
Guards: no self-referral, referrer must exist, first-touch attribution
only (a second ?start=ref_ link never overwrites it), and only takes
for genuinely new accounts (no existing subscriptions) — attaching a
referrer to an already-active user was never the intent.
Tested two ways, matching this repo's usual db.py-can-be-imported-
standalone / bot.py-needs-a-workaround split: 16 checks against a real
isolated sqlite db for the db.py logic (attribution, both reward paths,
double-reward guard, pending-bonus fold-in), then 9 more through an
actual `import bot` — aiogram/fastapi now have Python 3.14 wheels so
this imported for real rather than needing AST-extraction, modulo one
old blocker (xray_manager still imports the Unix-only fcntl for its
file lock) worked around with a tiny fake fcntl module in sys.modules,
same spirit as the fcntl shim already used elsewhere in this project's
history. Real start_deeplink and cb_referral calls, get_me() mocked to
avoid a live Telegram API call.
Not done: admin-panel UI toggle for REFERRAL_ENABLED/REFERRAL_BONUS_DAYS
(currently .env-only, like several other business tunables were before
they got a settings-page treatment) and a docs-tab writeup — happy to
add both if wanted, scoped this pass to the mechanic itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 22:29:15 +05:00
|
|
|
|
@dp.callback_query(F.data == "menu:referral")
|
|
|
|
|
|
async def cb_referral(cb: CallbackQuery):
|
|
|
|
|
|
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Назад", callback_data="menu:main")]])
|
|
|
|
|
|
ref_settings = settings.get_referral_settings()
|
|
|
|
|
|
if not ref_settings["enabled"]:
|
|
|
|
|
|
await cb.message.edit_text("Реферальная программа сейчас отключена.", reply_markup=kb)
|
|
|
|
|
|
return await cb.answer()
|
|
|
|
|
|
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
|
|
|
|
|
|
stats = db.referral_stats(cb.from_user.id)
|
|
|
|
|
|
username = await get_bot_username()
|
|
|
|
|
|
link = f"https://t.me/{username}?start=ref_{user['ref_code']}"
|
|
|
|
|
|
days = ref_settings["bonus_days"]
|
|
|
|
|
|
text = (
|
|
|
|
|
|
f"<b>Пригласи друга</b>\n\n"
|
|
|
|
|
|
f"За каждого друга, который активирует подписку по твоей ссылке, "
|
|
|
|
|
|
f"вы <b>оба</b> получаете +{days} дн. к подписке.\n\n"
|
|
|
|
|
|
f"{DIVIDER}\n"
|
|
|
|
|
|
f"Твоя ссылка:\n<code>{link}</code>\n\n"
|
|
|
|
|
|
f"Приглашено: {stats['referred_count']}\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
if stats["bonus_days_pending"]:
|
|
|
|
|
|
text += f"Накоплено бонусных дней (зачислятся при следующей подписке): {stats['bonus_days_pending']}\n"
|
|
|
|
|
|
await cb.message.edit_text(text, reply_markup=kb)
|
|
|
|
|
|
await cb.answer()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 17:45:43 +05:00
|
|
|
|
@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(
|
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
|
|
|
|
provider, payment_id, plan["price"], f"{settings.get_brand_name()} — {node_row['label']}, {plan['label']}",
|
2026-09-10 21:44:45 +05:00
|
|
|
|
)
|
|
|
|
|
|
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())
|