diff --git a/.env.example b/.env.example
index 8237c62..2bb624d 100644
--- a/.env.example
+++ b/.env.example
@@ -34,6 +34,8 @@ DE1_ADDRESS=de1.example.com
# Payments — off by default, bot keeps handing out free subscriptions on button press.
# Flip to true only once at least one provider below is configured and its webhook is live.
+# All of this (toggle, prices, provider keys) is also editable live from the admin panel
+# (Платежи tab) after first boot — no need to hand-edit this file or restart afterwards.
PAYMENTS_ENABLED=false
# Prices in RUB per plan (whole numbers). Only used when PAYMENTS_ENABLED=true.
@@ -58,6 +60,6 @@ PLATEGA_SECRET=
# Device limit (HWID) — off by default. Requires the VPN client app to send an
# x-hwid header on subscription fetch (Happ/v2rayTun-class apps do this); clients
# that don't send it get refused once enabled, so only flip this on if your users'
-# apps actually support it.
+# apps actually support it. Also editable live from Настройки in the admin panel.
HWID_LIMIT_ENABLED=false
HWID_FALLBACK_LIMIT=3
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f74f52f..e396f5f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -182,3 +182,61 @@ jobs:
print("all v1.1.0 feature smoke tests passed")
PYEOF
+
+ - name: Smoke test live settings (.env-backed plans/toggles/HWID/credentials, no restart)
+ env:
+ BOT_TOKEN: "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+ BOT_USERNAME: "x"
+ ADMIN_IDS: "1"
+ ADMIN_PANEL_PASSWORD: "ci-test-password-not-real"
+ PANEL_DOMAIN: "panel.test"
+ SUB_DOMAIN: "sub.test"
+ SITE_DOMAIN: "test"
+ XRAY_PUBLIC_KEY: "x"
+ XRAY_SHORT_ID_TCP: "x"
+ XRAY_SHORT_ID_GRPC: "x"
+ XRAY_SHORT_ID_XHTTP: "x"
+ run: |
+ python - << 'PYEOF'
+ import hashlib
+ import hmac
+
+ with open(".env", "a", encoding="utf-8") as f:
+ f.write("PLATEGA_SECRET=old_secret\n")
+ f.write("PLATEGA_ENABLED=true\n")
+ f.write("PLATEGA_MERCHANT_ID=m1\n")
+
+ import legal
+ import settings
+ import payments
+
+ plans = settings.get_plans_by_code()
+ assert plans["1m"]["price"] > 0, "default price should come from config before any .env override"
+
+ settings.set_plan_prices({"1m": 4242})
+ assert settings.get_plans_by_code()["1m"]["price"] == 4242, "price edit should apply live, no reimport"
+ assert settings.get_plans_by_code()["7d"]["price"] != 4242, "unrelated plan must stay untouched"
+
+ assert settings.get_hwid_settings()["enabled"] is False
+ legal.update_env_var("HWID_LIMIT_ENABLED", "true")
+ legal.update_env_var("HWID_FALLBACK_LIMIT", "9")
+ hwid = settings.get_hwid_settings()
+ assert hwid["enabled"] is True and hwid["fallback_limit"] == 9, "HWID settings should apply live"
+
+ body = b'{"transactionId":"t1","status":"CONFIRMED"}'
+ sig_old = hmac.new(b"old_secret", body, hashlib.sha256).hexdigest()
+ assert payments.verify_platega_signature(body, sig_old), "signature must verify against the current secret"
+
+ legal.update_env_var("PLATEGA_SECRET", "rotated_secret")
+ assert not payments.verify_platega_signature(body, sig_old), "OLD signature must be rejected right after rotation, same process, no restart"
+ sig_new = hmac.new(b"rotated_secret", body, hashlib.sha256).hexdigest()
+ assert payments.verify_platega_signature(body, sig_new), "NEW signature must verify immediately after rotation, same process, no restart"
+
+ for _ in range(5):
+ legal.update_env_var("HWID_FALLBACK_LIMIT", "9")
+ with open(".env", encoding="utf-8") as f:
+ lines = [l for l in f.readlines() if l.startswith("HWID_FALLBACK_LIMIT=")]
+ assert len(lines) == 1, "repeated writes to the same key must not duplicate .env lines"
+
+ print("live settings: prices/HWID/credential-rotation all apply with zero reimport OK")
+ PYEOF
diff --git a/admin.html b/admin.html
index 12c224d..1e175c7 100644
--- a/admin.html
+++ b/admin.html
@@ -539,7 +539,7 @@
Секретный ключ
Проверить и сохранить
- Панель сама постучится в ЮKassa (/v3/me) и сохранит ключи только если они рабочие. После сохранения включаются приём оплаты и вебхуки; бот перезапустится сам, для самой панели (обработка вебхуков) один раз выполни на сервере mbs restart.
+ Панель сама постучится в ЮKassa (/v3/me) и сохранит ключи только если они рабочие. После сохранения сразу включаются приём оплаты и приём вебхуков — без рестарта; бот на всякий случай перезапускается сам, чтобы кнопки оплаты в Telegram тоже обновились немедленно.
Platega — ключи API
@@ -549,10 +549,21 @@
Секрет
Сохранить
- У Platega нет публичного эндпоинта для проверки ключей без реального платежа, так что сохраняется без предварительной проверки — если ключи неверные, это будет видно по первой неудачной оплате. Тот же рестарт нужен, что и для ЮKassa.
+ У Platega нет публичного эндпоинта для проверки ключей без реального платежа, так что сохраняется без предварительной проверки — если ключи неверные, это будет видно по первой неудачной оплате. Применяется сразу, без рестарта.
+
+
Тарифы
+
Цены по срокам подписки и общий приём оплаты — меняются здесь, применяются сразу, рестарт не нужен.
+
Принимать оплату (если выключено — бот всегда выдаёт подписку бесплатно, как без платёжки вообще)
+
+
+ Сохранить тарифы
+
+
+
+
История
@@ -595,6 +606,12 @@
Вкладка Платежи → «Настройка приёма платежей» собирает публичную оферту и политику конфиденциальности (/offer, /privacy) из введённых реквизитов — ЮKassa их спросит при регистрации магазина. Дата вступления в силу проставляется один раз, правки реквизитов её не двигают.
Ключи ЮKassa проверяются вживую через их /v3/me перед сохранением; у Platega такого эндпоинта нет, ключи сохраняются без проверки. Оба провайдера включаются независимо.
Исходящие вебхуки (Настройки → Webhook на события) — панель стучится на указанный URL при оплате (payment.paid) и ручной выдаче подписки админом (subscription.granted_by_admin), тело подписано X-Signature (HMAC-SHA256). Секрет выдаётся один раз и не меняется при правке URL — для интеграций со своими системами, без опроса API.
+ Вкладка Платежи → «Тарифы» — цены по срокам и общий рубильник приёма оплаты. Как и реквизиты с ключами провайдеров, это читается панелью напрямую из .env при каждом запросе — правки в UI применяются мгновенно везде (бот, API, проверка вебхуков), рестарт панели нигде не требуется.
+
+
+
+
Лимит устройств (HWID)
+
Настройки → «Лимит устройств» — глобальный рубильник и лимит по умолчанию (как у Remnawave: клиент шлёт заголовок x-hwid при запросе конфига, панель запоминает первые N уникальных устройств на юзера и отказывает новым сверх лимита). У конкретного юзера лимит можно переопределить отдельно — в его карточке (Подписки → кнопка «Карточка» → таб «Устройства»), это имеет приоритет над глобальным значением по умолчанию.
+
+
+
Лимит устройств (HWID)
+
Ограничивает число разных устройств на одну подписку — как у Remnawave. У конкретного юзера лимит можно переопределить в его карточке, это значение — только дефолт для тех, у кого свой не задан.
+
Включить лимит устройств
+
+
Применяется сразу, без рестарта панели.
+
+
@@ -768,7 +797,7 @@ function showView(name) {
if (name === "nodes") loadNodes();
if (name === "traffic") loadTraffic();
if (name === "payments") { loadPayments(); loadPaymentsSettings(); }
- if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); loadWebhookSettings(); }
+ if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); loadWebhookSettings(); loadHwidSettings(); }
}
const COUNTRIES = [
@@ -1038,10 +1067,11 @@ async function checkPayment(id, btn) {
}
async function loadPaymentsSettings() {
- const [legalRes, ykRes, pgRes] = await Promise.all([
+ const [legalRes, ykRes, pgRes, planRes] = await Promise.all([
api("/admin/api/payments/legal-settings"),
api("/admin/api/payments/yookassa-settings"),
api("/admin/api/payments/platega-settings"),
+ api("/admin/api/payments/plan-settings"),
]);
document.getElementById("legal-name").value = legalRes.LEGAL_NAME || "";
document.getElementById("legal-inn").value = legalRes.LEGAL_INN || "";
@@ -1064,6 +1094,11 @@ async function loadPaymentsSettings() {
} else {
pgStatus.innerHTML = 'не настроена ';
}
+
+ document.getElementById("plan-payments-enabled").checked = !!planRes.payments_enabled;
+ document.getElementById("plan-price-inputs").innerHTML = planRes.plans.map((p) => `
+ ${esc(p.label)}
+ `).join("");
}
async function saveLegalSettings() {
@@ -1090,8 +1125,8 @@ async function saveYookassaSettings() {
if (!shop_id || !secret_key) return;
result.innerHTML = 'Проверяю ключи у ЮKassa…
';
try {
- const res = await api("/admin/api/payments/yookassa-settings", { method: "POST", body: JSON.stringify({ shop_id, secret_key }) });
- result.innerHTML = `Ключи рабочие, сохранено.${res.restarted_bot ? " Бот перезапущен." : " Бот сам не перезапустился — выполни mbs restart."} Для приёма вебхуков панелью выполни на сервере mbs restart.
`;
+ await api("/admin/api/payments/yookassa-settings", { method: "POST", body: JSON.stringify({ shop_id, secret_key }) });
+ result.innerHTML = 'Ключи рабочие, сохранено — бот и приём вебхуков подхватывают их сразу, рестарт не нужен.
';
document.getElementById("yk-secret-key").value = "";
loadPaymentsSettings();
} catch (e) {
@@ -1105,8 +1140,8 @@ async function savePlategaSettings() {
const result = document.getElementById("platega-result");
if (!merchant_id || !secret) return;
try {
- const res = await api("/admin/api/payments/platega-settings", { method: "POST", body: JSON.stringify({ merchant_id, secret }) });
- result.innerHTML = `Сохранено.${res.restarted_bot ? " Бот перезапущен." : " Бот сам не перезапустился — выполни mbs restart."} Для приёма вебхуков панелью выполни на сервере mbs restart.
`;
+ await api("/admin/api/payments/platega-settings", { method: "POST", body: JSON.stringify({ merchant_id, secret }) });
+ result.innerHTML = 'Сохранено — бот и приём вебхуков подхватывают ключи сразу, рестарт не нужен.
';
document.getElementById("pg-secret").value = "";
loadPaymentsSettings();
} catch (e) {
@@ -1114,6 +1149,47 @@ async function savePlategaSettings() {
}
}
+async function savePlanSettings() {
+ const result = document.getElementById("plan-settings-result");
+ const prices = {};
+ document.querySelectorAll(".plan-price-input").forEach((el) => {
+ prices[el.dataset.planCode] = el.value.trim();
+ });
+ const body = {
+ payments_enabled: document.getElementById("plan-payments-enabled").checked,
+ prices,
+ };
+ try {
+ await api("/admin/api/payments/plan-settings", { method: "POST", body: JSON.stringify(body) });
+ result.innerHTML = 'Сохранено — применилось сразу, без рестарта
';
+ loadPaymentsSettings();
+ } catch (e) {
+ result.innerHTML = 'Не получилось: ' + esc(e.message) + '
';
+ }
+}
+
+async function loadHwidSettings() {
+ const res = await api("/admin/api/hwid-settings");
+ document.getElementById("hwid-enabled").checked = !!res.enabled;
+ document.getElementById("hwid-fallback-limit").value = res.fallback_limit;
+ document.getElementById("hwid-result").innerHTML = "";
+}
+
+async function saveHwidSettings() {
+ const result = document.getElementById("hwid-result");
+ const body = {
+ enabled: document.getElementById("hwid-enabled").checked,
+ fallback_limit: document.getElementById("hwid-fallback-limit").value.trim(),
+ };
+ try {
+ await api("/admin/api/hwid-settings", { method: "POST", body: JSON.stringify(body) });
+ result.innerHTML = 'Сохранено — применилось сразу, без рестарта
';
+ loadHwidSettings();
+ } catch (e) {
+ result.innerHTML = 'Не получилось: ' + esc(e.message) + '
';
+ }
+}
+
async function loadBotSettings() {
const data = await api("/admin/api/settings/bot");
document.getElementById("settings-bot-username").textContent = "@" + data.username;
diff --git a/api.py b/api.py
index a538c89..477f6bb 100644
--- a/api.py
+++ b/api.py
@@ -17,14 +17,11 @@ import legal
import links
import nodeprov
import payments
+import settings
import totp
import webhooks
import xray_manager
-from config import (
- PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN,
- BOT_USERNAME, BOT_TOKEN, BASE_DIR,
- HWID_LIMIT_ENABLED, HWID_FALLBACK_LIMIT,
-)
+from config import SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, BOT_USERNAME, BOT_TOKEN, BASE_DIR
HWID_RE = re.compile(r"^[a-zA-Z0-9=-]{10,64}$")
ENV_PATH = os.path.join(BASE_DIR, ".env")
@@ -33,20 +30,7 @@ db.init_db()
def _update_env_var(key: str, value: str):
- lines = []
- if os.path.exists(ENV_PATH):
- with open(ENV_PATH, encoding="utf-8") as f:
- lines = f.readlines()
- found = False
- for i, line in enumerate(lines):
- if line.strip().startswith(f"{key}="):
- lines[i] = f"{key}={value}\n"
- found = True
- break
- if not found:
- lines.append(f"{key}={value}\n")
- with open(ENV_PATH, "w", encoding="utf-8") as f:
- f.writelines(lines)
+ legal.update_env_var(key, value)
app = FastAPI(title="mbs-api")
@@ -243,11 +227,12 @@ def get_subscription(token: str, request: Request):
sub_url = f"https://{SUB_DOMAIN}/sub/{token}"
return HTMLResponse(SUB_PAGE_TEMPLATE.format(sub_url=sub_url))
- if HWID_LIMIT_ENABLED:
+ hwid_cfg = settings.get_hwid_settings()
+ if hwid_cfg["enabled"]:
hwid = request.headers.get("x-hwid", "")
if not HWID_RE.match(hwid):
raise HTTPException(404, "hwid required")
- limit = user["hwid_limit"] if user["hwid_limit"] is not None else HWID_FALLBACK_LIMIT
+ limit = user["hwid_limit"] if user["hwid_limit"] is not None else hwid_cfg["fallback_limit"]
_, allowed = db.add_device_if_under_limit(
user["tg_id"], hwid, limit,
request.headers.get("x-device-os"),
@@ -267,9 +252,10 @@ def cabinet(token: str):
if not user:
raise HTTPException(404, "not found")
subs = db.list_active_subscriptions(tg_id=user["tg_id"])
+ plans_by_code = settings.get_plans_by_code()
out = []
for s in subs:
- plan = PLANS_BY_CODE.get(s["plan"])
+ plan = plans_by_code.get(s["plan"])
out.append({
"node": s["node"],
"plan": s["plan"],
@@ -313,7 +299,7 @@ def _grant_paid_subscription(payment_id: str):
payment = db.get_payment(payment_id)
if not payment or payment["status"] == "paid":
return
- plan = PLANS_BY_CODE.get(payment["plan"])
+ plan = settings.get_plans_by_code().get(payment["plan"])
node = db.get_node(payment["node"])
if not plan or not node:
return
@@ -361,10 +347,11 @@ def _check_and_reconcile_payment(payment: dict) -> str:
def admin_list_payments(request: Request):
require_admin(request)
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
+ plans_by_code = settings.get_plans_by_code()
out = []
for p in db.list_payments():
node = nodes_by_code.get(p["node"])
- plan = PLANS_BY_CODE.get(p["plan"])
+ plan = plans_by_code.get(p["plan"])
out.append({
**p,
"node_label": node["label"] if node else p["node"],
@@ -406,10 +393,11 @@ def admin_set_legal_settings(request: Request, body: dict = Body(...)):
@app.get("/admin/api/payments/yookassa-settings")
def admin_get_yookassa_settings(request: Request):
require_admin(request)
+ shop_id, secret_key = settings.yookassa_credentials()
return {
- "enabled": payments.YOOKASSA_ENABLED,
- "shop_id": legal.read_env_var("YOOKASSA_SHOP_ID", ""),
- "has_secret": bool(legal.read_env_var("YOOKASSA_SECRET_KEY", "")),
+ "enabled": settings.get_payment_settings()["yookassa_enabled"],
+ "shop_id": shop_id,
+ "has_secret": bool(secret_key),
}
@@ -440,10 +428,11 @@ def admin_set_yookassa_settings(request: Request, body: dict = Body(...)):
@app.get("/admin/api/payments/platega-settings")
def admin_get_platega_settings(request: Request):
require_admin(request)
+ merchant_id, secret = settings.platega_credentials()
return {
- "enabled": payments.PLATEGA_ENABLED,
- "merchant_id": legal.read_env_var("PLATEGA_MERCHANT_ID", ""),
- "has_secret": bool(legal.read_env_var("PLATEGA_SECRET", "")),
+ "enabled": settings.get_payment_settings()["platega_enabled"],
+ "merchant_id": merchant_id,
+ "has_secret": bool(secret),
}
@@ -467,6 +456,62 @@ def admin_set_platega_settings(request: Request, body: dict = Body(...)):
return {"ok": True, "restarted_bot": restarted}
+@app.get("/admin/api/payments/plan-settings")
+def admin_get_plan_settings(request: Request):
+ require_admin(request)
+ return {
+ "payments_enabled": settings.get_payment_settings()["payments_enabled"],
+ "plans": settings.get_plans(),
+ }
+
+
+@app.post("/admin/api/payments/plan-settings")
+def admin_set_plan_settings(request: Request, body: dict = Body(...)):
+ require_admin(request)
+ prices = body.get("prices") or {}
+ known_codes = settings.PRICE_ENV_KEYS.keys()
+ clean_prices = {}
+ for code, value in prices.items():
+ if code not in known_codes:
+ continue
+ try:
+ price = int(value)
+ except (TypeError, ValueError):
+ raise HTTPException(400, f"цена для тарифа {code} должна быть целым числом")
+ if price < 0:
+ raise HTTPException(400, f"цена для тарифа {code} не может быть отрицательной")
+ clean_prices[code] = price
+ settings.set_plan_prices(clean_prices)
+ if "payments_enabled" in body:
+ _update_env_var("PAYMENTS_ENABLED", "true" if body["payments_enabled"] else "false")
+ return {
+ "payments_enabled": settings.get_payment_settings()["payments_enabled"],
+ "plans": settings.get_plans(),
+ }
+
+
+@app.get("/admin/api/hwid-settings")
+def admin_get_hwid_settings(request: Request):
+ require_admin(request)
+ return settings.get_hwid_settings()
+
+
+@app.post("/admin/api/hwid-settings")
+def admin_set_hwid_settings(request: Request, body: dict = Body(...)):
+ require_admin(request)
+ if "enabled" in body:
+ _update_env_var("HWID_LIMIT_ENABLED", "true" if body["enabled"] else "false")
+ if "fallback_limit" in body:
+ try:
+ limit = int(body["fallback_limit"])
+ except (TypeError, ValueError):
+ raise HTTPException(400, "лимит устройств должен быть целым числом")
+ if not (1 <= limit <= 1000):
+ raise HTTPException(400, "лимит устройств должен быть от 1 до 1000")
+ _update_env_var("HWID_FALLBACK_LIMIT", str(limit))
+ return settings.get_hwid_settings()
+
+
@app.get("/admin/api/webhook-settings")
def admin_get_webhook_settings(request: Request):
require_admin(request)
@@ -813,10 +858,11 @@ def admin_subscriptions(request: Request, limit: int = 200):
require_admin(request)
subs = db.list_all_subscriptions(limit=limit)
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
+ plans_by_code = settings.get_plans_by_code()
out = []
for s in subs:
node = nodes_by_code.get(s["node"])
- plan = PLANS_BY_CODE.get(s["plan"])
+ plan = plans_by_code.get(s["plan"])
out.append({
**s,
"node_label": node["label"] if node else s["node"],
@@ -860,10 +906,11 @@ def admin_user_card(tg_id: int, request: Request):
raise HTTPException(404, "not found")
subs = db.list_subscriptions_for_user(tg_id)
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
+ plans_by_code = settings.get_plans_by_code()
out_subs = []
for s in subs:
node = nodes_by_code.get(s["node"])
- plan = PLANS_BY_CODE.get(s["plan"])
+ plan = plans_by_code.get(s["plan"])
out_subs.append({
**s,
"node_label": node["label"] if node else s["node"],
@@ -878,7 +925,7 @@ def admin_user_card(tg_id: int, request: Request):
"subscriptions": out_subs,
"devices": db.list_devices(tg_id),
"hwid_limit": user.get("hwid_limit"),
- "hwid_fallback_limit": HWID_FALLBACK_LIMIT,
+ "hwid_fallback_limit": settings.get_hwid_settings()["fallback_limit"],
}
@@ -888,7 +935,7 @@ def admin_grant_subscription(tg_id: int, request: Request, body: dict = Body(...
node_code = body.get("node")
plan_code = body.get("plan")
node = db.get_node(node_code)
- plan = PLANS_BY_CODE.get(plan_code)
+ plan = settings.get_plans_by_code().get(plan_code)
if not node or not plan:
raise HTTPException(400, "unknown node or plan")
db.get_or_create_user(tg_id, None)
@@ -907,7 +954,7 @@ def admin_list_devices(tg_id: int, request: Request):
return {
"devices": db.list_devices(tg_id),
"limit": db.get_or_create_user(tg_id, None).get("hwid_limit"),
- "fallback_limit": HWID_FALLBACK_LIMIT,
+ "fallback_limit": settings.get_hwid_settings()["fallback_limit"],
}
@@ -932,10 +979,11 @@ def admin_gift_codes(request: Request):
require_admin(request)
codes = db.list_gift_codes()
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
+ plans_by_code = settings.get_plans_by_code()
out = []
for c in codes:
node = nodes_by_code.get(c["node"])
- plan = PLANS_BY_CODE.get(c["plan"])
+ plan = plans_by_code.get(c["plan"])
out.append({
**c,
"node_label": node["label"] if node else c["node"],
@@ -949,7 +997,7 @@ def admin_gift_codes(request: Request):
def admin_create_gift_code(request: Request, body: dict = Body(...)):
require_admin(request)
node, plan = body.get("node"), body.get("plan")
- if node not in {n["code"] for n in db.list_nodes()} or plan not in PLANS_BY_CODE:
+ if node not in {n["code"] for n in db.list_nodes()} or plan not in settings.get_plans_by_code():
raise HTTPException(400, "invalid node/plan")
code = db.create_gift_code(node, plan, created_by=0)
return {"code": code, "link": f"https://t.me/{BOT_USERNAME}?start=gift_{code}"}
@@ -958,7 +1006,7 @@ def admin_create_gift_code(request: Request, body: dict = Body(...)):
@app.get("/admin/api/plans")
def admin_plans(request: Request):
require_admin(request)
- return PLANS
+ return settings.get_plans()
diff --git a/bot.py b/bot.py
index 86efcd4..0c9d09c 100644
--- a/bot.py
+++ b/bot.py
@@ -8,11 +8,11 @@ from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
import db
-import links
import payments
+import settings
import webhooks
import xray_manager
-from config import BOT_TOKEN, ADMIN_IDS, PLANS, PLANS_BY_CODE, SUB_DOMAIN, SITE_DOMAIN, PAYMENTS_ENABLED
+from config import BOT_TOKEN, ADMIN_IDS, SUB_DOMAIN, SITE_DOMAIN
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("mbs-bot")
@@ -49,9 +49,10 @@ def nodes_kb(prefix: str) -> InlineKeyboardMarkup:
def plans_kb(prefix: str, node_code: str) -> InlineKeyboardMarkup:
+ payments_enabled = settings.get_payment_settings()["payments_enabled"]
rows = []
- for p in PLANS:
- label = f"{p['label']} — {p['price']} ₽" if PAYMENTS_ENABLED and p["price"] > 0 else p["label"]
+ for p in settings.get_plans():
+ label = f"{p['label']} — {p['price']} ₽" if payments_enabled and p["price"] > 0 else p["label"]
rows.append([InlineKeyboardButton(text=label, callback_data=f"{prefix}:{node_code}:{p['code']}")])
rows.append([InlineKeyboardButton(text="Назад", callback_data="menu:get")])
return InlineKeyboardMarkup(inline_keyboard=rows)
@@ -97,7 +98,7 @@ async def start_deeplink(message: Message, command: CommandObject):
if err == "already_used":
await message.answer("Этот код уже был использован.")
return await send_main_menu(message)
- plan = PLANS_BY_CODE.get(gift["plan"])
+ plan = settings.get_plans_by_code().get(gift["plan"])
gift_node = db.get_node(gift["node"])
if not plan or not gift_node:
await message.answer("Этот подарок больше недоступен.")
@@ -162,10 +163,10 @@ def providers_kb(node_code: str, plan_code: str) -> InlineKeyboardMarkup:
@dp.callback_query(F.data.startswith("plan:"))
async def cb_plan(cb: CallbackQuery):
_, node_code, plan_code = cb.data.split(":")
- plan = PLANS_BY_CODE[plan_code]
+ plan = settings.get_plans_by_code()[plan_code]
db.get_or_create_user(cb.from_user.id, cb.from_user.username)
- if PAYMENTS_ENABLED and plan["price"] > 0 and payments.available_providers():
+ if settings.get_payment_settings()["payments_enabled"] and plan["price"] > 0 and payments.available_providers():
await cb.message.edit_text(
f"{plan['label']} — {plan['price']} ₽\n\nВыбери способ оплаты:",
reply_markup=providers_kb(node_code, plan_code),
@@ -194,7 +195,7 @@ async def cb_plan(cb: CallbackQuery):
@dp.callback_query(F.data.startswith("pay:"))
async def cb_pay(cb: CallbackQuery):
_, provider, node_code, plan_code = cb.data.split(":")
- plan = PLANS_BY_CODE[plan_code]
+ plan = settings.get_plans_by_code()[plan_code]
node_row = db.get_node(node_code)
payment_id = payments.new_payment_id()
db.create_payment(payment_id, cb.from_user.id, node_code, plan_code, provider, plan["price"])
@@ -228,8 +229,9 @@ async def cb_mysub(cb: CallbackQuery):
return await cb.answer()
lines = ["Твои подписки \n"]
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
+ plans_by_code = settings.get_plans_by_code()
for s in subs:
- plan = PLANS_BY_CODE.get(s["plan"], {}).get("label", s["plan"])
+ plan = plans_by_code.get(s["plan"], {}).get("label", s["plan"])
node_info = nodes_by_code.get(s["node"])
node = node_info["label"] if node_info else s["node"]
lines.append(f"{node} — {plan}, до {s['expires_at'][:10]}")
@@ -284,7 +286,7 @@ async def cb_admin_giftmake(cb: CallbackQuery):
me = await bot.get_me()
_bot_username = me.username
link = f"https://t.me/{_bot_username}?start=gift_{code}"
- plan = PLANS_BY_CODE[plan_code]
+ plan = settings.get_plans_by_code()[plan_code]
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="В админку", callback_data="menu:admin")]])
await cb.message.edit_text(
f"Гифт-ссылка готова ({db.get_node(node_code)['label']}, {plan['label']}):\n\n"
@@ -325,9 +327,10 @@ async def cb_admin_sync(cb: CallbackQuery):
async def reconcile_pending_payments():
- if not PAYMENTS_ENABLED:
+ if not settings.get_payment_settings()["payments_enabled"]:
return
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
+ plans_by_code = settings.get_plans_by_code()
for payment in db.list_payments():
if payment["status"] != "pending" or not payment.get("external_id"):
continue
@@ -336,7 +339,7 @@ async def reconcile_pending_payments():
except Exception:
continue
if status in payments.PAID_STATUSES:
- plan = PLANS_BY_CODE.get(payment["plan"])
+ plan = plans_by_code.get(payment["plan"])
node_row = nodes_by_code.get(payment["node"])
if not plan or not node_row:
continue
diff --git a/legal.py b/legal.py
index 36c3039..c882724 100644
--- a/legal.py
+++ b/legal.py
@@ -9,19 +9,47 @@ SITE_DIR = os.path.join(BASE_DIR, "site")
FIELD_KEYS = ["LEGAL_NAME", "LEGAL_INN", "REFUND_HOURS", "SUPPORT_CONTACT", "SUPPORT_EMAIL", "OFFER_EFFECTIVE_DATE"]
-def read_env_var(key: str, default: str = "") -> str:
+def read_env_vars(keys: list) -> dict:
+ result = {key: None for key in keys}
if not os.path.exists(ENV_PATH):
- return default
+ return result
+ wanted = set(keys)
with open(ENV_PATH, encoding="utf-8") as f:
for line in f:
line = line.strip()
- if line.startswith(f"{key}="):
- return line[len(key) + 1:]
- return default
+ if "=" not in line or line.startswith("#"):
+ continue
+ key, _, value = line.partition("=")
+ if key in wanted and result[key] is None:
+ result[key] = value
+ return result
+
+
+def read_env_var(key: str, default: str = "") -> str:
+ value = read_env_vars([key])[key]
+ return default if value is None else value
+
+
+def update_env_var(key: str, value: str):
+ lines = []
+ if os.path.exists(ENV_PATH):
+ with open(ENV_PATH, encoding="utf-8") as f:
+ lines = f.readlines()
+ found = False
+ for i, line in enumerate(lines):
+ if line.strip().startswith(f"{key}="):
+ lines[i] = f"{key}={value}\n"
+ found = True
+ break
+ if not found:
+ lines.append(f"{key}={value}\n")
+ with open(ENV_PATH, "w", encoding="utf-8") as f:
+ f.writelines(lines)
def get_settings() -> dict:
- return {key: read_env_var(key) for key in FIELD_KEYS}
+ raw = read_env_vars(FIELD_KEYS)
+ return {key: (raw[key] or "") for key in FIELD_KEYS}
def _fallback(label: str) -> str:
diff --git a/payments.py b/payments.py
index 441175d..22f0d78 100644
--- a/payments.py
+++ b/payments.py
@@ -5,20 +5,18 @@ import json
import secrets
import urllib.request
-from config import (
- PANEL_DOMAIN,
- YOOKASSA_ENABLED, YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY,
- PLATEGA_ENABLED, PLATEGA_MERCHANT_ID, PLATEGA_SECRET,
-)
+from config import PANEL_DOMAIN
+import settings
PROVIDER_NAMES = {"yookassa": "ЮKassa", "platega": "Platega"}
def available_providers() -> list[str]:
+ enabled = settings.get_payment_settings()
providers = []
- if YOOKASSA_ENABLED:
+ if enabled["yookassa_enabled"]:
providers.append("yookassa")
- if PLATEGA_ENABLED:
+ if enabled["platega_enabled"]:
providers.append("platega")
return providers
@@ -41,7 +39,8 @@ def _get_json(url: str, headers: dict, timeout: int = 15) -> dict:
def create_yookassa_payment(payment_id: str, amount_rub: int, description: str) -> str:
- auth = base64.b64encode(f"{YOOKASSA_SHOP_ID}:{YOOKASSA_SECRET_KEY}".encode()).decode()
+ shop_id, secret_key = settings.yookassa_credentials()
+ auth = base64.b64encode(f"{shop_id}:{secret_key}".encode()).decode()
data = _post_json(
"https://api.yookassa.ru/v3/payments",
{
@@ -72,7 +71,8 @@ def verify_yookassa_notification(body: dict) -> bool:
def check_yookassa_payment(external_id: str) -> str:
- auth = base64.b64encode(f"{YOOKASSA_SHOP_ID}:{YOOKASSA_SECRET_KEY}".encode()).decode()
+ shop_id, secret_key = settings.yookassa_credentials()
+ auth = base64.b64encode(f"{shop_id}:{secret_key}".encode()).decode()
data = _get_json(
f"https://api.yookassa.ru/v3/payments/{external_id}",
{"Authorization": f"Basic {auth}"},
@@ -81,6 +81,7 @@ def check_yookassa_payment(external_id: str) -> str:
def create_platega_payment(payment_id: str, amount_rub: int, description: str) -> str:
+ merchant_id, secret = settings.platega_credentials()
data = _post_json(
"https://app.platega.io/transaction/process",
{
@@ -92,8 +93,8 @@ def create_platega_payment(payment_id: str, amount_rub: int, description: str) -
},
{
"Content-Type": "application/json",
- "X-MerchantId": PLATEGA_MERCHANT_ID,
- "X-Secret": PLATEGA_SECRET,
+ "X-MerchantId": merchant_id,
+ "X-Secret": secret,
},
)
external_id = data.get("id") or data.get("transactionId")
@@ -104,14 +105,16 @@ def create_platega_payment(payment_id: str, amount_rub: int, description: str) -
def verify_platega_signature(raw_body: bytes, signature: str) -> bool:
if not signature:
return False
- expected = hmac.new(PLATEGA_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
+ _, secret = settings.platega_credentials()
+ expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
def check_platega_payment(external_id: str) -> str:
+ merchant_id, secret = settings.platega_credentials()
data = _get_json(
f"https://app.platega.io/transaction/{external_id}",
- {"X-MerchantId": PLATEGA_MERCHANT_ID, "X-Secret": PLATEGA_SECRET},
+ {"X-MerchantId": merchant_id, "X-Secret": secret},
)
return data.get("status", "")
diff --git a/settings.py b/settings.py
new file mode 100644
index 0000000..42a9a51
--- /dev/null
+++ b/settings.py
@@ -0,0 +1,76 @@
+import config
+import legal
+
+PRICE_ENV_KEYS = {"7d": "PRICE_7D", "1m": "PRICE_1M", "3m": "PRICE_3M", "6m": "PRICE_6M", "1y": "PRICE_1Y"}
+
+
+def _bool(raw: str, default: bool) -> bool:
+ if raw is None or raw == "":
+ return default
+ return raw.strip().lower() == "true"
+
+
+def _positive_int(raw: str, default: int) -> int:
+ if raw and raw.strip().lstrip("-").isdigit():
+ parsed = int(raw)
+ if parsed >= 0:
+ return parsed
+ return default
+
+
+def get_plans() -> list:
+ raw = legal.read_env_vars(list(PRICE_ENV_KEYS.values()))
+ plans = []
+ for p in config.PLANS:
+ env_key = PRICE_ENV_KEYS[p["code"]]
+ plans.append({
+ "code": p["code"],
+ "label": p["label"],
+ "days": p["days"],
+ "price": _positive_int(raw.get(env_key), p["price"]),
+ })
+ return plans
+
+
+def get_plans_by_code() -> dict:
+ return {p["code"]: p for p in get_plans()}
+
+
+def set_plan_prices(prices: dict):
+ for code, price in prices.items():
+ if code in PRICE_ENV_KEYS:
+ legal.update_env_var(PRICE_ENV_KEYS[code], str(int(price)))
+
+
+def get_payment_settings() -> dict:
+ raw = legal.read_env_vars(["PAYMENTS_ENABLED", "YOOKASSA_ENABLED", "PLATEGA_ENABLED"])
+ return {
+ "payments_enabled": _bool(raw.get("PAYMENTS_ENABLED"), config.PAYMENTS_ENABLED),
+ "yookassa_enabled": _bool(raw.get("YOOKASSA_ENABLED"), config.YOOKASSA_ENABLED),
+ "platega_enabled": _bool(raw.get("PLATEGA_ENABLED"), config.PLATEGA_ENABLED),
+ }
+
+
+def yookassa_credentials():
+ raw = legal.read_env_vars(["YOOKASSA_SHOP_ID", "YOOKASSA_SECRET_KEY"])
+ return (
+ raw.get("YOOKASSA_SHOP_ID") or config.YOOKASSA_SHOP_ID,
+ raw.get("YOOKASSA_SECRET_KEY") or config.YOOKASSA_SECRET_KEY,
+ )
+
+
+def platega_credentials():
+ raw = legal.read_env_vars(["PLATEGA_MERCHANT_ID", "PLATEGA_SECRET"])
+ return (
+ raw.get("PLATEGA_MERCHANT_ID") or config.PLATEGA_MERCHANT_ID,
+ raw.get("PLATEGA_SECRET") or config.PLATEGA_SECRET,
+ )
+
+
+def get_hwid_settings() -> dict:
+ raw = legal.read_env_vars(["HWID_LIMIT_ENABLED", "HWID_FALLBACK_LIMIT"])
+ limit = _positive_int(raw.get("HWID_FALLBACK_LIMIT"), config.HWID_FALLBACK_LIMIT)
+ return {
+ "enabled": _bool(raw.get("HWID_LIMIT_ENABLED"), config.HWID_LIMIT_ENABLED),
+ "fallback_limit": limit if limit > 0 else config.HWID_FALLBACK_LIMIT,
+ }