Initial commit: VPN Telegram bot (sanitized defaults)

This commit is contained in:
VPN Service
2026-07-26 23:21:41 +03:00
commit aaf04faa96
77 changed files with 33249 additions and 0 deletions
View File
+37
View File
@@ -0,0 +1,37 @@
"""Алерты админам в Telegram (health / ошибки оплат)."""
from __future__ import annotations
import logging
import time
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from aiogram import Bot
from config import Settings
logger = logging.getLogger(__name__)
_last_sent: dict[str, float] = {}
async def alert_admins(
bot: Bot | None,
settings: Settings,
text: str,
*,
key: str = "generic",
cooldown_sec: float = 300.0,
) -> None:
if bot is None or not settings.admin_ids:
return
now = time.monotonic()
last = _last_sent.get(key, 0.0)
if now - last < cooldown_sec:
return
_last_sent[key] = now
for admin_id in settings.admin_ids:
try:
await bot.send_message(admin_id, text, parse_mode="HTML")
except Exception: # noqa: BLE001
logger.warning("admin alert failed id=%s", admin_id)
+289
View File
@@ -0,0 +1,289 @@
"""Клиент AnyPay (https://anypay.io) — SCI + API create-payment."""
from __future__ import annotations
import hashlib
import hmac
import logging
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlencode
import httpx
logger = logging.getLogger(__name__)
API_BASE = "https://anypay.io/api"
MERCHANT_URL = "https://anypay.io/merchant"
PAID_STATUSES = frozenset({"paid", "success"})
ANYPAY_IPS = frozenset({"185.162.128.38", "185.162.128.39", "185.162.128.88"})
class AnypayError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class AnypayInvoice:
pay_id: str
pay_url: str
transaction_id: str | None = None
status: str = "waiting"
amount: float = 0.0
def _fmt_amount(amount: float) -> str:
return f"{float(amount):.2f}"
def sign_create_payment(
*,
api_id: str,
project_id: str,
pay_id: str,
amount: str,
currency: str,
desc: str,
method: str,
api_key: str,
) -> str:
raw = (
f"create-payment{api_id}{project_id}{pay_id}{amount}"
f"{currency}{desc}{method}{api_key}"
)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def sign_sci(
*,
project_id: str,
pay_id: str,
amount: str,
currency: str,
desc: str,
success_url: str,
fail_url: str,
api_key: str,
) -> str:
raw = ":".join(
[
str(project_id),
str(pay_id),
amount,
currency,
desc,
success_url or "",
fail_url or "",
api_key,
]
)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def verify_notification_sign(
data: dict[str, Any],
*,
project_id: str,
api_key: str,
) -> bool:
"""Проверка подписи IPN (SHA256 или MD5)."""
received = str(data.get("sign") or "").strip()
if not received or not api_key:
return False
currency = str(data.get("currency") or "")
amount = str(data.get("amount") or "")
pay_id = str(data.get("pay_id") or "")
merchant_id = str(data.get("merchant_id") or project_id)
status = str(data.get("status") or "paid")
# SHA256: currency:amount:pay_id:merchant_id:status:secret
sha_raw = ":".join([currency, amount, pay_id, str(merchant_id), status, api_key])
sha_expected = hashlib.sha256(sha_raw.encode("utf-8")).hexdigest()
if hmac.compare_digest(received.lower(), sha_expected.lower()):
return True
if status.lower() != "paid":
sha_paid = ":".join(
[currency, amount, pay_id, str(merchant_id), "paid", api_key]
)
sha_paid_expected = hashlib.sha256(sha_paid.encode("utf-8")).hexdigest()
if hmac.compare_digest(received.lower(), sha_paid_expected.lower()):
return True
# MD5: merchant_id:amount:pay_id:secret
md5_raw = f"{merchant_id}:{amount}:{pay_id}:{api_key}"
md5_expected = hashlib.md5(md5_raw.encode("utf-8")).hexdigest()
return hmac.compare_digest(received.lower(), md5_expected.lower())
def client_ip_allowed(remote: str | None, *, forwarded: str | None = None) -> bool:
candidates: list[str] = []
if remote:
candidates.append(remote.strip())
if forwarded:
first = forwarded.split(",")[0].strip()
if first:
candidates.append(first)
return any(ip in ANYPAY_IPS for ip in candidates)
class Anypay:
def __init__(
self,
api_id: str,
api_key: str,
project_id: str,
*,
currency: str = "RUB",
method: str = "",
success_url: str = "",
fail_url: str = "",
default_email: str = "pay@anypay.local",
check_ip: bool = False,
) -> None:
self.api_id = str(api_id or "").strip()
self.api_key = str(api_key or "").strip()
self.project_id = str(project_id or "").strip()
self.currency = (currency or "RUB").strip().upper() or "RUB"
self.method = (method or "").strip().lower()
self.success_url = (success_url or "").strip()
self.fail_url = (fail_url or "").strip()
self.default_email = (default_email or "pay@anypay.local").strip()
self.check_ip = bool(check_ip)
@property
def enabled(self) -> bool:
return bool(self.api_id and self.api_key and self.project_id)
def build_sci_pay_url(
self,
*,
pay_id: str,
amount_rub: float,
desc: str,
email: str = "",
) -> str:
if not self.enabled:
raise AnypayError("AnyPay не настроен")
amount = _fmt_amount(amount_rub)
currency = self.currency
description = (desc or "Topup")[:150]
sign = sign_sci(
project_id=self.project_id,
pay_id=str(pay_id),
amount=amount,
currency=currency,
desc=description,
success_url=self.success_url,
fail_url=self.fail_url,
api_key=self.api_key,
)
params: dict[str, str] = {
"merchant_id": self.project_id,
"pay_id": str(pay_id),
"amount": amount,
"currency": currency,
"desc": description,
"email": (email or self.default_email)[:120],
"sign": sign,
"lang": "ru",
}
if self.success_url:
params["success_url"] = self.success_url
if self.fail_url:
params["fail_url"] = self.fail_url
if self.method:
params["method"] = self.method
return f"{MERCHANT_URL}?{urlencode(params)}"
async def create_invoice(
self,
*,
pay_id: str,
amount_rub: float,
desc: str,
email: str = "",
) -> AnypayInvoice:
"""Создать счёт: API (если задан method) или SCI-ссылка."""
if not self.enabled:
raise AnypayError("AnyPay не настроен (ANYPAY_API_ID / KEY / PROJECT_ID)")
pay_id_s = "".join(ch for ch in str(pay_id) if ch.isdigit())
if not pay_id_s or len(pay_id_s) > 15:
raise AnypayError("pay_id: только цифры, до 15 символов")
amount = _fmt_amount(amount_rub)
description = (desc or "Topup")[:150]
mail = (email or self.default_email)[:120]
# Без method — SCI (покупатель выбирает способ на странице AnyPay)
if not self.method:
url = self.build_sci_pay_url(
pay_id=pay_id_s,
amount_rub=amount_rub,
desc=description,
email=mail,
)
return AnypayInvoice(
pay_id=pay_id_s,
pay_url=url,
amount=float(amount),
status="waiting",
)
sign = sign_create_payment(
api_id=self.api_id,
project_id=self.project_id,
pay_id=pay_id_s,
amount=amount,
currency=self.currency,
desc=description,
method=self.method,
api_key=self.api_key,
)
params: dict[str, Any] = {
"project_id": self.project_id,
"pay_id": pay_id_s,
"amount": amount,
"currency": self.currency,
"desc": description,
"email": mail,
"method": self.method,
"sign": sign,
"lang": "ru",
}
if self.success_url:
params["success_url"] = self.success_url
if self.fail_url:
params["fail_url"] = self.fail_url
url = f"{API_BASE}/create-payment/{self.api_id}"
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(
url, params=params, headers={"Accept": "application/json"}
)
try:
data = resp.json()
except Exception as exc: # noqa: BLE001
raise AnypayError(f"AnyPay bad JSON: {resp.text[:200]}") from exc
if not isinstance(data, dict):
raise AnypayError(f"AnyPay unexpected response: {data!r}")
if data.get("error"):
raise AnypayError(str(data.get("error") or data))
result = data.get("result")
if not isinstance(result, dict):
raise AnypayError(f"AnyPay: нет result ({data})")
pay_url = str(result.get("payment_url") or "").strip()
if not pay_url:
raise AnypayError("AnyPay: пустой payment_url")
return AnypayInvoice(
pay_id=str(result.get("pay_id") or pay_id_s),
pay_url=pay_url,
transaction_id=str(result.get("transaction_id") or "") or None,
status=str(result.get("status") or "waiting"),
amount=float(amount),
)
+297
View File
@@ -0,0 +1,297 @@
"""Клиент Amnezia Web Panel (AWG / AWG 2.0) — Bearer API token."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
import httpx
logger = logging.getLogger(__name__)
DEFAULT_PROTOCOL = "awg2"
class AwgError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class AwgPanelServer:
id: int
name: str
host: str
protocols: list[str]
@property
def has_awg2(self) -> bool:
return any(
str(p).split("__", 1)[0] in {"awg2", "awg"} for p in self.protocols
)
@dataclass(frozen=True, slots=True)
class AwgConnectionInfo:
ok: bool
alive: bool
ping_ms: int | None
protocols: list[str]
message: str = ""
def _normalize_base_url(url: str) -> str:
raw = (url or "").strip().rstrip("/")
if not raw:
raise AwgError("API URL пуст")
if not raw.startswith(("http://", "https://")):
raw = "https://" + raw
parsed = urlparse(raw)
if not parsed.netloc:
raise AwgError("Некорректный API URL")
return raw
class AwgClient:
"""HTTP-клиент к Amnezia Web Panel (panel5 / PRVTPRO)."""
def __init__(
self,
api_url: str,
*,
api_token: str,
verify_ssl: bool = True,
timeout: float = 60.0,
) -> None:
self.base_url = _normalize_base_url(api_url)
self.api_token = (api_token or "").strip()
if not self.api_token:
raise AwgError("Нужен API Token (Settings → API Tokens)")
self.verify_ssl = verify_ssl
self._client = httpx.AsyncClient(
base_url=self.base_url,
timeout=timeout,
verify=verify_ssl,
headers={
"Authorization": f"Bearer {self.api_token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
)
async def __aenter__(self) -> "AwgClient":
return self
async def __aexit__(self, *args: Any) -> None:
await self.aclose()
async def aclose(self) -> None:
await self._client.aclose()
async def _request(
self,
method: str,
path: str,
*,
json_body: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
try:
res = await self._client.request(
method, path, json=json_body, params=params
)
except httpx.HTTPError as exc:
raise AwgError(f"Сеть: {exc}") from exc
if res.status_code in (401, 403):
raise AwgError("Доступ запрещён — проверьте API Token")
if res.status_code == 404:
raise AwgError("Не найдено (проверьте server_id на панели)")
data: Any
ctype = res.headers.get("content-type", "")
if "application/json" in ctype or res.text[:1] in "{[":
try:
data = res.json()
except Exception:
data = {"raw": res.text[:500]}
else:
data = {"raw": res.text[:500]}
if res.status_code >= 400:
err = ""
if isinstance(data, dict):
err = str(data.get("error") or data.get("detail") or "")
raise AwgError(err or f"HTTP {res.status_code}")
return data
async def ping(self, panel_server_id: int) -> dict[str, Any]:
return await self._request("GET", f"/api/servers/{int(panel_server_id)}/ping")
async def list_panel_servers(self) -> list[AwgPanelServer]:
"""Список нод панели через backup download (единственный полный inventory API)."""
try:
res = await self._client.get("/api/settings/backup/download")
except httpx.HTTPError as exc:
raise AwgError(f"Сеть: {exc}") from exc
if res.status_code in (401, 403):
raise AwgError("Доступ запрещён — проверьте API Token")
if res.status_code >= 400:
raise AwgError(f"Не удалось загрузить список серверов (HTTP {res.status_code})")
try:
data = res.json()
except Exception as exc:
raise AwgError("Ответ панели не JSON") from exc
servers = data.get("servers") if isinstance(data, dict) else None
if not isinstance(servers, list):
raise AwgError("В backup нет списка servers")
out: list[AwgPanelServer] = []
for i, s in enumerate(servers):
if not isinstance(s, dict):
continue
protos = s.get("protocols") or {}
keys = list(protos.keys()) if isinstance(protos, dict) else []
out.append(
AwgPanelServer(
id=i,
name=str(s.get("name") or s.get("host") or f"Server #{i}"),
host=str(s.get("host") or ""),
protocols=keys,
)
)
return out
async def probe(
self, panel_server_id: int, *, protocol: str = DEFAULT_PROTOCOL
) -> AwgConnectionInfo:
try:
ping = await self.ping(panel_server_id)
except AwgError as exc:
return AwgConnectionInfo(
ok=False, alive=False, ping_ms=None, protocols=[], message=str(exc)
)
alive = bool(ping.get("alive"))
ms = ping.get("ms")
try:
ms_int = int(ms) if ms is not None else None
except (TypeError, ValueError):
ms_int = None
protocols: list[str] = []
try:
servers = await self.list_panel_servers()
for s in servers:
if s.id == int(panel_server_id):
protocols = s.protocols
break
except AwgError:
protocols = []
base = (protocol or DEFAULT_PROTOCOL).split("__", 1)[0]
has_proto = any(str(p).split("__", 1)[0] == base for p in protocols) if protocols else True
if not alive:
return AwgConnectionInfo(
ok=False,
alive=False,
ping_ms=ms_int,
protocols=protocols,
message=str(ping.get("error") or "SSH недоступен"),
)
if protocols and not has_proto:
return AwgConnectionInfo(
ok=False,
alive=True,
ping_ms=ms_int,
protocols=protocols,
message=f"Протокол {base} не найден на сервере",
)
return AwgConnectionInfo(
ok=True,
alive=True,
ping_ms=ms_int,
protocols=protocols,
message=f"OK · ping {ms_int or ''} ms",
)
async def list_connections(
self, panel_server_id: int, *, protocol: str = DEFAULT_PROTOCOL
) -> list[dict[str, Any]]:
data = await self._request(
"GET",
f"/api/servers/{int(panel_server_id)}/connections",
params={"protocol": protocol or DEFAULT_PROTOCOL},
)
clients = data.get("clients") if isinstance(data, dict) else None
return clients if isinstance(clients, list) else []
async def add_connection(
self,
panel_server_id: int,
*,
name: str,
protocol: str = DEFAULT_PROTOCOL,
) -> dict[str, Any]:
data = await self._request(
"POST",
f"/api/servers/{int(panel_server_id)}/connections/add",
json_body={
"protocol": protocol or DEFAULT_PROTOCOL,
"name": (name or "Client").strip()[:80] or "Client",
},
)
if not isinstance(data, dict):
raise AwgError("Пустой ответ при создании клиента")
if not data.get("client_id") and not data.get("config"):
raise AwgError("Панель не вернула client_id/config")
return data
async def remove_connection(
self,
panel_server_id: int,
*,
client_id: str,
protocol: str = DEFAULT_PROTOCOL,
) -> None:
await self._request(
"POST",
f"/api/servers/{int(panel_server_id)}/connections/remove",
json_body={
"protocol": protocol or DEFAULT_PROTOCOL,
"client_id": client_id,
},
)
async def get_connection_config(
self,
panel_server_id: int,
*,
client_id: str,
protocol: str = DEFAULT_PROTOCOL,
) -> dict[str, Any]:
data = await self._request(
"POST",
f"/api/servers/{int(panel_server_id)}/connections/config",
json_body={
"protocol": protocol or DEFAULT_PROTOCOL,
"client_id": client_id,
},
)
return data if isinstance(data, dict) else {}
async def toggle_connection(
self,
panel_server_id: int,
*,
client_id: str,
enable: bool,
protocol: str = DEFAULT_PROTOCOL,
) -> None:
await self._request(
"POST",
f"/api/servers/{int(panel_server_id)}/connections/toggle",
json_body={
"protocol": protocol or DEFAULT_PROTOCOL,
"client_id": client_id,
"enable": bool(enable),
},
)
+488
View File
@@ -0,0 +1,488 @@
"""Покупка и управление AmneziaWG 2.0 конфигами через Amnezia Web Panel."""
from __future__ import annotations
import logging
import secrets
import string
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from remnawave import RemnawaveSDK
from services.awg_client import DEFAULT_PROTOCOL, AwgClient, AwgError
from services.db import Database
from services.xui_wg import subscription_is_active, _subscription_limits
logger = logging.getLogger(__name__)
MAX_AWG_CONFIGS = 10
SETTING_ENABLED = "awg_purchase_enabled"
SETTING_PRICE = "awg_config_price"
DEFAULT_PRICE = 0.0
def _truthy(value: str) -> bool:
return (value or "").strip().lower() in ("1", "true", "yes", "on", "да")
def _format_price(price: float) -> str:
if price <= 0:
return "бесплатно"
if price == int(price):
return f"{int(price)}"
return f"{price:.2f}"
@dataclass(slots=True)
class AwgPurchaseSettings:
enabled: bool = False
price: float = DEFAULT_PRICE
max_configs: int = MAX_AWG_CONFIGS
def as_dict(self) -> dict[str, Any]:
return {
"enabled": self.enabled,
"price": self.price,
"price_label": _format_price(self.price),
"max_configs": self.max_configs,
}
@dataclass(slots=True)
class AwgActionResult:
ok: bool
message: str
balance: float = 0.0
config: dict[str, Any] | None = None
configs: list[dict[str, Any]] | None = None
def load_awg_purchase_settings(db: Database) -> AwgPurchaseSettings:
raw = db.get_settings_map([SETTING_ENABLED, SETTING_PRICE])
price_raw = (raw.get(SETTING_PRICE) or "").strip()
try:
price = float(price_raw) if price_raw else DEFAULT_PRICE
except ValueError:
price = DEFAULT_PRICE
return AwgPurchaseSettings(
enabled=_truthy(raw.get(SETTING_ENABLED, "")),
price=max(0.0, price),
max_configs=MAX_AWG_CONFIGS,
)
def save_awg_purchase_settings(
db: Database,
*,
enabled: bool | None = None,
price: float | None = None,
) -> AwgPurchaseSettings:
data: dict[str, str] = {}
if enabled is not None:
data[SETTING_ENABLED] = "1" if enabled else "0"
if price is not None:
data[SETTING_PRICE] = str(max(0.0, float(price)))
if data:
db.set_settings_map(data)
return load_awg_purchase_settings(db)
def _client_from_server(row: dict[str, Any]) -> AwgClient:
return AwgClient(
str(row["api_url"]),
api_token=str(row.get("api_token") or ""),
verify_ssl=bool(row.get("verify_ssl", True)),
)
def _new_client_name(telegram_id: int, remark: str = "") -> str:
if remark.strip():
return remark.strip()[:80]
suffix = "".join(
secrets.choice(string.ascii_lowercase + string.digits) for _ in range(4)
)
return f"awg_{telegram_id}_{suffix}"
def serialize_awg_config(
row: dict[str, Any], server: dict[str, Any] | None = None
) -> dict[str, Any]:
expire = row.get("expire_at")
return {
"id": int(row["id"]),
"server_id": int(row["server_id"]),
"server_name": (server or {}).get("name") or row.get("server_name") or "",
"client_id": row.get("client_id") or "",
"client_name": row.get("client_name") or "",
"remark": row.get("remark") or "",
"vpn_link": row.get("vpn_link") or "",
"config": row.get("config_text") or "",
"enabled": bool(row.get("enabled", True)),
"protocol": row.get("protocol") or DEFAULT_PROTOCOL,
"expire_at": expire.isoformat() if isinstance(expire, datetime) else expire,
"qr_url": f"/api/awg/configs/{int(row['id'])}/qr",
"created_at": row["created_at"].isoformat()
if isinstance(row.get("created_at"), datetime)
else row.get("created_at"),
}
def list_available_awg_servers(db: Database) -> list[dict[str, Any]]:
rows = db.list_awg_servers(enabled_only=True)
out: list[dict[str, Any]] = []
for r in rows:
if not str(r.get("api_token") or "").strip():
continue
out.append(
{
"id": int(r["id"]),
"name": r.get("name") or f"AWG #{r['id']}",
"protocol": r.get("protocol") or DEFAULT_PROTOCOL,
"panel_server_id": int(r.get("panel_server_id") or 0),
}
)
return out
def list_user_awg_configs(db: Database, telegram_id: int) -> list[dict[str, Any]]:
rows = db.list_awg_configs(telegram_id)
return [serialize_awg_config(r) for r in rows]
async def purchase_awg_config(
*,
db: Database,
sdk: RemnawaveSDK,
telegram_id: int,
server_id: int,
remark: str = "",
login: str | None = None,
) -> AwgActionResult:
settings = load_awg_purchase_settings(db)
bot_user = db.ensure_user(telegram_id)
balance = bot_user.balance
login = login or bot_user.login
if not settings.enabled:
return AwgActionResult(
ok=False,
message="Покупка AmneziaWG временно отключена.",
balance=balance,
)
active, reason = await subscription_is_active(
sdk, telegram_id, login=login, db=db
)
if not active:
return AwgActionResult(ok=False, message=reason, balance=balance)
count = db.count_awg_configs(telegram_id)
if count >= settings.max_configs:
return AwgActionResult(
ok=False,
message=f"Лимит: максимум {settings.max_configs} AWG-конфигов на аккаунт.",
balance=balance,
)
server = db.get_awg_server(server_id)
if not server or not server.get("enabled"):
return AwgActionResult(ok=False, message="Сервер недоступен.", balance=balance)
price = float(settings.price)
if price > 0:
ok, balance = db.try_charge(
telegram_id,
price,
kind="awg_config",
meta=f"awg:{server_id}",
)
if not ok:
return AwgActionResult(
ok=False,
message=(
f"Недостаточно средств.\n"
f"Нужно: <b>{_format_price(price)}</b>\n"
f"Баланс: <b>{balance:.0f} ₽</b>"
),
balance=balance,
)
client_name = _new_client_name(telegram_id, remark)
remark = (remark or "").strip()[:80] or client_name
protocol = str(server.get("protocol") or DEFAULT_PROTOCOL)
panel_sid = int(server.get("panel_server_id") or 0)
try:
expire_at, _traffic = await _subscription_limits(
sdk, telegram_id, login=login, db=db
)
async with _client_from_server(server) as client:
created = await client.add_connection(
panel_sid, name=client_name, protocol=protocol
)
client_id = str(created.get("client_id") or "")
config_text = str(created.get("config") or "")
vpn_link = str(created.get("vpn_link") or "")
if not vpn_link and config_text:
import base64
vpn_link = "vpn://" + base64.b64encode(
config_text.strip().encode("utf-8")
).decode("utf-8")
row = db.create_awg_config(
telegram_id=telegram_id,
server_id=server_id,
client_id=client_id,
client_name=client_name,
remark=remark,
config_text=config_text,
vpn_link=vpn_link,
protocol=protocol,
enabled=True,
expire_at=expire_at,
)
except Exception as exc: # noqa: BLE001
logger.exception("AWG config create failed")
if price > 0:
balance = db.add_balance(
telegram_id,
price,
kind="refund",
meta=f"awg_refund:{server_id}",
)
return AwgActionResult(
ok=False,
message=f"Не удалось создать конфиг: {exc}",
balance=balance,
)
return AwgActionResult(
ok=True,
message="AmneziaWG-конфиг создан",
balance=balance,
config=serialize_awg_config(row, server),
)
async def set_awg_config_enabled(
*,
db: Database,
telegram_id: int,
config_id: int,
enabled: bool,
sdk: RemnawaveSDK | None = None,
login: str | None = None,
) -> AwgActionResult:
bot_user = db.ensure_user(telegram_id)
login = login or bot_user.login
row = db.get_awg_config(config_id, telegram_id=telegram_id)
if not row:
return AwgActionResult(ok=False, message="Конфиг не найден", balance=bot_user.balance)
if enabled:
if sdk is None:
return AwgActionResult(
ok=False,
message="Не удалось проверить подписку.",
balance=bot_user.balance,
)
active, reason = await subscription_is_active(
sdk, telegram_id, login=login, db=db
)
if not active:
return AwgActionResult(ok=False, message=reason, balance=bot_user.balance)
server = db.get_awg_server(int(row["server_id"]))
if not server:
return AwgActionResult(ok=False, message="Сервер не найден", balance=bot_user.balance)
try:
async with _client_from_server(server) as client:
await client.toggle_connection(
int(server["panel_server_id"]),
client_id=str(row["client_id"]),
enable=enabled,
protocol=str(row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL),
)
db.update_awg_config(config_id, enabled=enabled)
except AwgError as exc:
return AwgActionResult(ok=False, message=str(exc), balance=bot_user.balance)
except Exception as exc: # noqa: BLE001
logger.exception("AWG toggle failed")
return AwgActionResult(ok=False, message=f"Ошибка: {exc}", balance=bot_user.balance)
refreshed = db.get_awg_config(config_id, telegram_id=telegram_id)
return AwgActionResult(
ok=True,
message="Включено" if enabled else "Выключено",
balance=bot_user.balance,
config=serialize_awg_config(refreshed, server) if refreshed else None,
)
async def delete_awg_config(
*,
db: Database,
telegram_id: int,
config_id: int,
) -> AwgActionResult:
row = db.get_awg_config(config_id, telegram_id=telegram_id)
if not row:
return AwgActionResult(ok=False, message="Конфиг не найден")
server = db.get_awg_server(int(row["server_id"]))
if server:
try:
async with _client_from_server(server) as client:
await client.remove_connection(
int(server["panel_server_id"]),
client_id=str(row["client_id"]),
protocol=str(
row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL
),
)
except Exception: # noqa: BLE001
logger.exception("AWG remove on panel failed (deleting local anyway)")
db.delete_awg_config(config_id, telegram_id=telegram_id)
return AwgActionResult(ok=True, message="Удалено")
async def refresh_awg_config_payload(
*,
db: Database,
telegram_id: int,
config_id: int,
) -> AwgActionResult:
row = db.get_awg_config(config_id, telegram_id=telegram_id)
if not row:
return AwgActionResult(ok=False, message="Конфиг не найден")
server = db.get_awg_server(int(row["server_id"]))
if not server:
return AwgActionResult(ok=False, message="Сервер не найден")
try:
async with _client_from_server(server) as client:
data = await client.get_connection_config(
int(server["panel_server_id"]),
client_id=str(row["client_id"]),
protocol=str(
row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL
),
)
config_text = str(data.get("config") or row.get("config_text") or "")
vpn_link = str(data.get("vpn_link") or "")
if not vpn_link and config_text:
import base64
vpn_link = "vpn://" + base64.b64encode(
config_text.strip().encode("utf-8")
).decode("utf-8")
db.update_awg_config(
config_id,
config_text=config_text,
vpn_link=vpn_link or row.get("vpn_link") or "",
)
except Exception as exc: # noqa: BLE001
logger.exception("AWG refresh failed")
return AwgActionResult(ok=False, message=f"Не удалось обновить: {exc}")
refreshed = db.get_awg_config(config_id, telegram_id=telegram_id)
return AwgActionResult(
ok=True,
message="Обновлено",
config=serialize_awg_config(refreshed, server) if refreshed else None,
)
async def sync_awg_configs_expiry(
*,
db: Database,
sdk: RemnawaveSDK,
telegram_id: int,
login: str | None = None,
restore_enabled: bool = False,
) -> None:
"""Обновляет expire_at у локальных AWG-конфигов по подписке Remnawave."""
rows = db.list_awg_configs(telegram_id)
if not rows:
return
bot_user = db.ensure_user(telegram_id)
login = login or bot_user.login
try:
expire_at, _ = await _subscription_limits(
sdk, telegram_id, login=login, db=db
)
except Exception: # noqa: BLE001
return
for row in rows:
db.update_awg_config(int(row["id"]), expire_at=expire_at)
if restore_enabled and not row.get("enabled"):
server = db.get_awg_server(int(row["server_id"]))
if not server:
continue
try:
async with _client_from_server(server) as client:
await client.toggle_connection(
int(server["panel_server_id"]),
client_id=str(row["client_id"]),
enable=True,
protocol=str(
row.get("protocol")
or server.get("protocol")
or DEFAULT_PROTOCOL
),
)
db.update_awg_config(int(row["id"]), enabled=True)
except Exception as exc: # noqa: BLE001
logger.warning(
"AWG restore enable failed config=%s: %s",
row.get("id"),
exc,
)
async def _disable_awg_row(db: Database, row: dict[str, Any]) -> bool:
if not row.get("enabled"):
return False
server = db.get_awg_server(int(row["server_id"]))
if server:
try:
async with _client_from_server(server) as client:
await client.toggle_connection(
int(server["panel_server_id"]),
client_id=str(row["client_id"]),
enable=False,
protocol=str(
row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL
),
)
except Exception as exc: # noqa: BLE001
logger.warning(
"AWG auto-disable panel failed config=%s: %s",
row.get("id"),
exc,
)
db.update_awg_config(int(row["id"]), enabled=False)
return True
async def enforce_awg_configs_subscription(
*,
db: Database,
sdk: RemnawaveSDK,
telegram_id: int,
login: str | None = None,
) -> dict[str, Any]:
"""Нет активной подписки → выключить все AWG-конфиги на панели и в БД."""
bot_user = db.ensure_user(telegram_id)
login = login or bot_user.login
active, reason = await subscription_is_active(
sdk, telegram_id, login=login, db=db
)
disabled = 0
if not active:
for row in db.list_awg_configs(telegram_id):
if await _disable_awg_row(db, row):
disabled += 1
return {
"subscription_active": active,
"reason": "" if active else reason,
"disabled": disabled,
}
+216
View File
@@ -0,0 +1,216 @@
"""Топ клиентов по https://docs.rw/clients (featured + самые популярные)."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class ClientLink:
title: str
url: str
@dataclass(frozen=True, slots=True)
class ClientApp:
id: str
name: str
core: str
description: str
featured: bool
hwid: bool
links: tuple[ClientLink, ...]
# Источник: https://docs.rw/clients + src/data/clients.ts (featured первыми)
TOP_CLIENTS: tuple[ClientApp, ...] = (
ClientApp(
id="happ",
name="Happ",
core="Xray",
description="Современный клиент для Android, iOS, macOS, Windows, Linux.",
featured=True,
hwid=True,
links=(
ClientLink("Android · Google Play", "https://play.google.com/store/apps/details?id=com.happproxy"),
ClientLink(
"Android · APK",
"https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
),
ClientLink("iOS · App Store", "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215"),
ClientLink(
"Windows",
"https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x64.exe",
),
ClientLink("Linux · релизы", "https://github.com/Happ-proxy/happ-desktop/releases/latest"),
ClientLink("Сайт", "https://happ.su/main"),
),
),
ClientApp(
id="flclashx",
name="FlClashX",
core="Mihomo",
description="Форк FlClash с улучшениями. Android / Windows / macOS / Linux.",
featured=True,
hwid=True,
links=(
ClientLink("GitHub · релизы", "https://github.com/pluralplay/FlClashX/releases/latest"),
ClientLink("Telegram", "https://t.me/flclashx"),
),
),
ClientApp(
id="koala-clash",
name="Koala Clash",
core="Mihomo",
description="Форк Clash Verge Rev (community Remnawave). Windows / macOS / Linux.",
featured=True,
hwid=True,
links=(
ClientLink(
"GitHub · релизы",
"https://github.com/coolcoala/clash-verge-rev-lite/releases/latest",
),
ClientLink("Telegram", "https://t.me/+WCL__GOFzZJkYjZi"),
),
),
ClientApp(
id="flowvy",
name="Flowvy",
core="Mihomo",
description="Удобный Mihomo-клиент «в два клика». Windows / macOS / Linux.",
featured=True,
hwid=True,
links=(
ClientLink(
"Windows",
"https://github.com/flowvy-proxy/desktop/releases/latest/download/Flowvy_x64.exe",
),
ClientLink(
"Linux · deb",
"https://github.com/flowvy-proxy/desktop/releases/latest/download/Flowvy_x64.deb",
),
ClientLink("macOS · релизы", "https://github.com/flowvy-proxy/desktop/releases/latest"),
ClientLink("Docs", "https://docs.flowvy.io"),
),
),
ClientApp(
id="rabbit-hole",
name="Rabbit Hole",
core="Mihomo",
description="Чистый UX для iOS и macOS.",
featured=True,
hwid=True,
links=(
ClientLink(
"iOS / macOS · App Store",
"https://apps.apple.com/app/rabbithole-vpn-client/id6683309629",
),
ClientLink("Docs", "https://rabbit-hole-1.gitbook.io/rabbit-hole-docs/"),
ClientLink("Telegram", "https://t.me/rabbit_hole_vpn"),
),
),
ClientApp(
id="v2raytun",
name="V2rayTun",
core="Xray",
description="Лёгкий Xray-клиент. Android / iOS / Windows / macOS.",
featured=False,
hwid=True,
links=(
ClientLink(
"Android · Google Play",
"https://play.google.com/store/apps/details?id=com.v2raytun.android",
),
ClientLink("iOS / macOS", "https://apps.apple.com/en/app/v2raytun/id6476628951"),
ClientLink("Windows / сайт", "https://v2raytun.com/"),
ClientLink("Telegram", "https://t.me/v2raytun"),
),
),
ClientApp(
id="v2rayng",
name="V2rayNG",
core="Xray",
description="Самый популярный V2Ray/Xray клиент для Android.",
featured=False,
hwid=False,
links=(
ClientLink("Android · релизы", "https://github.com/2dust/v2rayNG/releases/latest"),
ClientLink("GitHub", "https://github.com/2dust/v2rayNG"),
),
),
ClientApp(
id="v2rayn",
name="V2rayN",
core="Xray",
description="Популярный клиент для Windows / macOS / Linux.",
featured=False,
hwid=False,
links=(
ClientLink("Релизы", "https://github.com/2dust/v2rayN/releases/latest"),
ClientLink("Wiki", "https://github.com/2dust/v2rayN/wiki"),
),
),
ClientApp(
id="clash-verge",
name="Clash Verge",
core="Mihomo",
description="Clash Meta GUI на Tauri. Windows / macOS / Linux.",
featured=False,
hwid=False,
links=(
ClientLink(
"Релизы",
"https://github.com/clash-verge-rev/clash-verge-rev/releases/latest",
),
ClientLink("Telegram", "https://t.me/clash_verge_rev"),
),
),
ClientApp(
id="karing",
name="Karing",
core="Sing-box",
description="Мультиплатформенный клиент на Sing-box.",
featured=False,
hwid=True,
links=(
ClientLink("Android / Desktop · релизы", "https://github.com/KaringX/karing/releases/latest"),
ClientLink("iOS · App Store", "https://apps.apple.com/us/app/karing/id6472431552"),
ClientLink("Сайт", "https://karing.app/"),
ClientLink("Telegram", "https://t.me/KaringApp"),
),
),
)
DOCS_CLIENTS_URL = "https://docs.rw/clients"
CLIENTS_INTRO = (
"<b>📥 Скачать клиент</b>\n\n"
"Топ‑10 по <a href=\"https://docs.rw/clients\">docs.rw/clients</a> "
"(featured Remnawave + самые популярные).\n\n"
"Установи приложение и вставь ссылку из «Моя подписка».\n"
"Выбери клиент:"
)
def get_client(client_id: str) -> ClientApp | None:
for client in TOP_CLIENTS:
if client.id == client_id:
return client
return None
def client_card(client: ClientApp) -> str:
badges: list[str] = []
if client.featured:
badges.append("⭐ featured")
if client.hwid:
badges.append("🪪 HWID")
badge_line = " · ".join(badges)
lines = [
f"<b>{client.name}</b>",
f"Core: <code>{client.core}</code>",
]
if badge_line:
lines.append(badge_line)
lines.extend(["", client.description, "", "Выбери платформу / ссылку:"])
return "\n".join(lines)
+107
View File
@@ -0,0 +1,107 @@
"""Клиент Crypto Pay API (@CryptoBot)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
class CryptoPayError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class Invoice:
invoice_id: int
status: str
amount: str
pay_url: str
bot_invoice_url: str
payload: str | None = None
fiat: str | None = None
class CryptoPay:
def __init__(self, token: str, *, testnet: bool = False) -> None:
self.token = token.strip()
self.base_url = (
"https://testnet-pay.crypt.bot/api"
if testnet
else "https://pay.crypt.bot/api"
)
@property
def enabled(self) -> bool:
return bool(self.token)
async def _call(self, method: str, **params: Any) -> Any:
if not self.enabled:
raise CryptoPayError("CRYPTO_BOT_TOKEN не задан")
headers = {"Crypto-Pay-API-Token": self.token}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{self.base_url}/{method}",
headers=headers,
json={k: v for k, v in params.items() if v is not None},
)
resp.raise_for_status()
data = resp.json()
if not data.get("ok"):
raise CryptoPayError(str(data.get("error") or data))
return data["result"]
async def get_me(self) -> dict[str, Any]:
return await self._call("getMe")
async def create_fiat_invoice(
self,
*,
amount_rub: float,
description: str,
payload: str,
expires_in: int = 1800,
accepted_assets: str = "USDT,TON,BTC,ETH,LTC",
) -> Invoice:
result = await self._call(
"createInvoice",
currency_type="fiat",
fiat="RUB",
amount=f"{amount_rub:.2f}",
description=description[:1024],
payload=payload[:4096],
accepted_assets=accepted_assets,
expires_in=expires_in,
allow_comments=False,
allow_anonymous=True,
)
return self._parse_invoice(result)
async def get_invoice(self, invoice_id: int) -> Invoice | None:
result = await self._call(
"getInvoices",
invoice_ids=str(invoice_id),
)
items = result if isinstance(result, list) else result.get("items") or []
if not items:
return None
return self._parse_invoice(items[0])
@staticmethod
def _parse_invoice(raw: dict[str, Any]) -> Invoice:
pay_url = (
raw.get("bot_invoice_url")
or raw.get("pay_url")
or raw.get("mini_app_invoice_url")
or ""
)
return Invoice(
invoice_id=int(raw["invoice_id"]),
status=str(raw.get("status") or "active"),
amount=str(raw.get("amount") or ""),
pay_url=pay_url,
bot_invoice_url=str(raw.get("bot_invoice_url") or pay_url),
payload=raw.get("payload"),
fiat=raw.get("fiat"),
)
+3229
View File
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
"""Лимит устройств (HWID): база 10, каждое сверх — 50 ₽."""
from __future__ import annotations
from dataclasses import dataclass
BASE_DEVICES = 10
EXTRA_DEVICE_PRICE_RUB = 50.0
# Сколько устройств панель может зарегистрировать максимум (мягкий потолок).
# Реальная квота: BASE_DEVICES + оплаченные слоты; при превышении — DISABLE.
PANEL_SOFT_CAP = 100
@dataclass(frozen=True, slots=True)
class DevicePack:
slots: int
price: float
@property
def id(self) -> str:
return f"dev_{self.slots}"
@property
def title(self) -> str:
return f"+{self.slots} устр."
def format_price(self) -> str:
if self.price == int(self.price):
return f"{int(self.price)} ₽/мес"
return f"{self.price:.2f} ₽/мес"
DEVICE_PACKS: tuple[DevicePack, ...] = tuple(
DevicePack(slots=n, price=EXTRA_DEVICE_PRICE_RUB * n) for n in (1, 2, 3, 5)
)
def get_device_pack(pack_id: str) -> DevicePack | None:
for p in DEVICE_PACKS:
if p.id == pack_id or str(p.slots) == pack_id:
return p
return None
@dataclass(frozen=True, slots=True)
class DeviceUsage:
used: int
allowed: int
extra_paid: int
over_limit: bool
@property
def excess(self) -> int:
return max(0, self.used - self.allowed)
@property
def excess_price(self) -> float:
return self.excess * EXTRA_DEVICE_PRICE_RUB
def format_line(self) -> str:
line = (
f"📱 Устройства: <b>{self.used} / {self.allowed}</b> "
f"(база {BASE_DEVICES}"
+ (f" + {self.extra_paid} доп." if self.extra_paid else "")
+ ")"
)
if self.over_limit:
line += (
f"\n⚠️ Превышение: <b>+{self.excess}</b> · "
f"к оплате <b>{int(self.excess_price)} ₽</b>\n"
"Подписка отключена, пока не оплатишь лишние устройства "
f"({int(EXTRA_DEVICE_PRICE_RUB)} ₽ за каждое)."
)
return line
def as_dict(self) -> dict:
return {
"used": self.used,
"allowed": self.allowed,
"base": BASE_DEVICES,
"extra_paid": self.extra_paid,
"over_limit": self.over_limit,
"excess": self.excess,
"excess_price": self.excess_price,
"slot_price": EXTRA_DEVICE_PRICE_RUB,
}
def allowed_devices(extra_paid: int) -> int:
return BASE_DEVICES + max(0, int(extra_paid or 0))
+273
View File
@@ -0,0 +1,273 @@
"""Клиент Digiseller: проверка оплаты по уникальному коду."""
from __future__ import annotations
import base64
import hashlib
import logging
import time
from dataclasses import dataclass
from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
import httpx
logger = logging.getLogger(__name__)
API_BASE = "https://api.digiseller.com"
API_BASE_FALLBACK = "https://oplata.info"
class DigisellerError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class DigisellerPurchase:
unique_code: str
inv: int
product_id: int
amount: float
currency: str
cnt_goods: float
email: str | None
state: int
raw: dict[str, Any]
@property
def delivered(self) -> bool:
# 2/3/4 — уже отмечено как выдано
return self.state in {2, 3, 4}
class Digiseller:
def __init__(
self,
seller_id: str | int,
api_key: str,
*,
product_ids: tuple[int, ...] = (),
pay_url: str = "",
credit_from: str = "cnt_goods",
) -> None:
self.seller_id = str(seller_id or "").strip()
self.api_key = (api_key or "").strip()
self.product_ids = product_ids
self.pay_url = (pay_url or "").strip()
self.credit_from = (credit_from or "cnt_goods").strip().lower()
self._token: str | None = None
self._token_expires: float = 0.0
@property
def enabled(self) -> bool:
return bool(self.seller_id and self.api_key)
def _sign(self, timestamp: int) -> str:
raw = f"{self.api_key}{timestamp}".encode("utf-8")
return hashlib.sha256(raw).hexdigest()
async def _login(self) -> str:
now = time.time()
if self._token and now < self._token_expires - 60:
return self._token
timestamp = int(now)
payload = {
"seller_id": int(self.seller_id),
"timestamp": timestamp,
"sign": self._sign(timestamp),
}
data = await self._request("POST", "/api/apilogin", json_body=payload, auth=False)
if int(data.get("retval") or -1) != 0 or not data.get("token"):
raise DigisellerError(data.get("desc") or data.get("retdesc") or "login failed")
self._token = str(data["token"])
# токен живёт ~2 часа
self._token_expires = now + 110 * 60
return self._token
async def _request(
self,
method: str,
path: str,
*,
json_body: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
auth: bool = True,
) -> dict[str, Any]:
if not self.enabled:
raise DigisellerError("Digiseller не настроен")
headers = {"Accept": "application/json", "Content-Type": "application/json"}
query = dict(params or {})
if auth:
query["token"] = await self._login()
last_exc: Exception | None = None
for base in (API_BASE, API_BASE_FALLBACK):
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.request(
method,
f"{base}{path}",
headers=headers,
params=query or None,
json=json_body,
)
if resp.status_code == 401 and auth:
self._token = None
query["token"] = await self._login()
resp = await client.request(
method,
f"{base}{path}",
headers=headers,
params=query,
json=json_body,
)
data = resp.json()
if not isinstance(data, dict):
raise DigisellerError(f"bad response: {resp.text[:200]}")
return data
except Exception as exc: # noqa: BLE001
last_exc = exc
logger.warning("Digiseller %s %s via %s failed: %s", method, path, base, exc)
raise DigisellerError(str(last_exc or "request failed"))
async def get_by_unique_code(self, unique_code: str) -> DigisellerPurchase:
code = "".join(ch for ch in unique_code.strip() if ch.isalnum())
if len(code) < 10:
raise DigisellerError("Некорректный уникальный код")
data = await self._request(
"GET",
f"/api/purchases/unique-code/{code}",
)
retval = int(data.get("retval") if data.get("retval") is not None else -1)
if retval != 0:
raise DigisellerError(
str(data.get("retdesc") or data.get("desc") or f"код ошибки {retval}")
)
state_obj = data.get("unique_code_state") or {}
state = int(state_obj.get("state") or 0)
inv = int(data.get("inv") or 0)
product_id = int(data.get("id_goods") or 0)
if not inv:
raise DigisellerError("В ответе Digiseller нет номера счёта (inv)")
return DigisellerPurchase(
unique_code=code,
inv=inv,
product_id=product_id,
amount=float(data.get("amount") or 0),
currency=str(data.get("type_curr") or ""),
cnt_goods=float(data.get("cnt_goods") or 0),
email=(str(data.get("email")).strip() if data.get("email") else None),
state=state,
raw=data,
)
def assert_payable(self, purchase: DigisellerPurchase) -> None:
"""Проверка, что код можно принять к зачислению."""
# 1 — ещё не верифицирован (первый запрос), 5 — верифицирован, товар не выдан
# 2/3 — уже отмечен выданным (допускаем для дозачисления после сбоя)
# 4 — оспорен
if purchase.state == 4:
raise DigisellerError("Оплата по этому коду оспорена")
if purchase.state not in {1, 2, 3, 5}:
raise DigisellerError(
f"Код ещё не готов к выдаче (state={purchase.state}). "
"Оплати товар и попробуй снова."
)
date_pay = str((purchase.raw or {}).get("date_pay") or "").strip()
if not date_pay and purchase.state == 1:
raise DigisellerError("Код ещё не оплачен")
async def mark_delivered(self, unique_code: str) -> None:
code = "".join(ch for ch in unique_code.strip() if ch.isalnum())
data = await self._request(
"PUT",
f"/api/purchases/unique-code/{code}/deliver",
)
retval = int(data.get("retval") if data.get("retval") is not None else -1)
# 4 — уже выдано ранее (идемпотентно ок)
if retval not in (0, 4):
raise DigisellerError(
str(data.get("retdesc") or data.get("desc") or f"deliver error {retval}")
)
def credit_amount(self, purchase: DigisellerPurchase) -> float:
"""Сколько рублей зачислить на баланс бота."""
if self.product_ids and purchase.product_id not in self.product_ids:
raise DigisellerError(
f"Товар id={purchase.product_id} не разрешён для пополнения"
)
if self.credit_from == "amount":
value = purchase.amount
else:
# Типичная схема: товар без фикс. цены, cnt_goods = сумма в ₽
value = purchase.cnt_goods if purchase.cnt_goods > 0 else purchase.amount
if value <= 0:
raise DigisellerError("Сумма пополнения равна 0")
return float(value)
def default_pay_url(self) -> str:
if self.pay_url:
return self.pay_url
if self.product_ids:
return f"https://oplata.info/asp2/pay.asp?id_d={self.product_ids[0]}"
return "https://my.digiseller.com/"
def pay_url_for(self, telegram_id: int) -> str:
"""Ссылка на оплату с привязкой к Telegram ID (уходит в query_string)."""
base = self.default_pay_url()
parsed = urlparse(base)
q = parse_qs(parsed.query, keep_blank_values=True)
q["telegram_id"] = [str(int(telegram_id))]
# совместимость с разными интеграциями Digiseller
q["tg_id"] = [str(int(telegram_id))]
new_query = urlencode({k: v[-1] for k, v in q.items()})
return urlunparse(parsed._replace(query=new_query))
@staticmethod
def extract_telegram_id(purchase: DigisellerPurchase) -> int | None:
"""Достать telegram_id из query_string / options платежа."""
raw = purchase.raw or {}
qs_b64 = str(raw.get("query_string") or "").strip()
if qs_b64:
try:
pad = "=" * (-len(qs_b64) % 4)
decoded = base64.b64decode(qs_b64 + pad).decode("utf-8", "ignore")
params = parse_qs(decoded, keep_blank_values=True)
for key in ("telegram_id", "tg_id", "tg", "user_id", "uid"):
vals = params.get(key) or []
if vals:
tid = int(str(vals[0]).strip())
if tid > 0:
return tid
except Exception: # noqa: BLE001
logger.debug("Digiseller query_string parse failed", exc_info=True)
for opt in raw.get("options") or []:
if not isinstance(opt, dict):
continue
name = str(opt.get("name") or "").strip().lower()
value = str(opt.get("value") or "").strip()
if not value:
continue
if any(
x in name
for x in ("telegram", "телеграм", "tg id", "tg_id", "userid", "user id")
):
digits = "".join(ch for ch in value if ch.isdigit())
if digits:
tid = int(digits)
if tid > 0:
return tid
# иногда покупатель просто вписывает числовой ID
if value.isdigit() and len(value) >= 5:
return int(value)
return None
+127
View File
@@ -0,0 +1,127 @@
"""Автозачисление по уникальному коду Digiseller."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from aiogram import Bot
from config import get_settings
from services.db import Database
from services.digiseller import Digiseller, DigisellerError
from services.mailer import notify_important
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class DigisellerFulfillResult:
ok: bool
message: str
amount: float = 0.0
balance: float = 0.0
telegram_id: int | None = None
already: bool = False
inv: int | None = None
async def fulfill_digiseller_unique_code(
*,
db: Database,
digiseller: Digiseller,
unique_code: str,
telegram_id_hint: int | None = None,
bot: Bot | None = None,
notify: bool = True,
) -> DigisellerFulfillResult:
"""Проверить код в Digiseller API и зачислить баланс. Идемпотентно."""
purchase = await digiseller.get_by_unique_code(unique_code)
digiseller.assert_payable(purchase)
amount = digiseller.credit_amount(purchase)
telegram_id = digiseller.extract_telegram_id(purchase) or telegram_id_hint
if not telegram_id:
raise DigisellerError(
"В платеже нет telegram_id. Открой оплату из бота "
"(ссылка с твоим ID) или введи код вручную в боте."
)
if db.digiseller_inv_used(purchase.inv) or db.digiseller_code_used(
purchase.unique_code
):
balance = db.get_balance(telegram_id)
return DigisellerFulfillResult(
ok=True,
already=True,
message="Этот платёж уже был зачислен ранее.",
amount=amount,
balance=balance,
telegram_id=telegram_id,
inv=purchase.inv,
)
recorded = db.record_digiseller_payment(
inv=purchase.inv,
unique_code=purchase.unique_code,
telegram_id=telegram_id,
amount_rub=amount,
product_id=purchase.product_id,
)
if not recorded:
balance = db.get_balance(telegram_id)
return DigisellerFulfillResult(
ok=True,
already=True,
message="Этот платёж уже был зачислен ранее.",
amount=amount,
balance=balance,
telegram_id=telegram_id,
inv=purchase.inv,
)
balance = db.add_balance(
telegram_id,
amount,
kind="digiseller_topup",
meta=f"digi:{purchase.inv}:{purchase.unique_code}",
)
try:
await digiseller.mark_delivered(purchase.unique_code)
except DigisellerError as exc:
logger.warning(
"Digiseller deliver failed inv=%s: %s", purchase.inv, exc
)
text = (
f"✅ <b>Баланс пополнен через Digiseller</b>\n\n"
f"+{amount:.0f}\n"
f"Счёт Digiseller: <code>{purchase.inv}</code>\n"
f"Доступно: <b>{balance:.0f} ₽</b>"
)
if notify:
try:
settings = get_settings(require_bot_token=False)
await notify_important(
db=db,
settings=settings,
telegram_id=telegram_id,
html=text,
subject="Баланс пополнен — VPN Service",
bot=bot,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"Digiseller notify failed tg=%s: %s", telegram_id, exc
)
return DigisellerFulfillResult(
ok=True,
message=text,
amount=amount,
balance=balance,
telegram_id=telegram_id,
inv=purchase.inv,
)
+194
View File
@@ -0,0 +1,194 @@
"""Чтение/запись allowlist-ключей в .env (админка платежей)."""
from __future__ import annotations
import os
import re
from pathlib import Path
from dotenv import dotenv_values
from config import ROOT_DIR
# Ключи, которыми можно управлять из админки
PAYMENT_ENV_KEYS: tuple[str, ...] = (
"CRYPTO_BOT_TOKEN",
"CRYPTO_PAY_TESTNET",
"HELEKET_MERCHANT_ID",
"HELEKET_API_KEY",
"HELEKET_CALLBACK_URL",
"HELEKET_CURRENCY",
"LAVA_SHOP_ID",
"LAVA_SECRET_KEY",
"LAVA_ADDITIONAL_KEY",
"LAVA_HOOK_URL",
"LAVA_SUCCESS_URL",
"LAVA_FAIL_URL",
"LAVA_EXPIRE_MINUTES",
"DIGISELLER_SELLER_ID",
"DIGISELLER_API_KEY",
"DIGISELLER_PRODUCT_IDS",
"DIGISELLER_PAY_URL",
"DIGISELLER_CREDIT_FROM",
"DIGISELLER_CALLBACK_URL",
"ANYPAY_API_ID",
"ANYPAY_API_KEY",
"ANYPAY_PROJECT_ID",
"ANYPAY_CURRENCY",
"ANYPAY_METHOD",
"ANYPAY_SUCCESS_URL",
"ANYPAY_FAIL_URL",
"ANYPAY_EMAIL",
"ANYPAY_CHECK_IP",
"STARS_ENABLED",
"STARS_RUB_PER_STAR",
"TOPUP_CODE_PAY_URL",
)
SECRET_ENV_KEYS = frozenset(
{
"CRYPTO_BOT_TOKEN",
"HELEKET_API_KEY",
"LAVA_SECRET_KEY",
"LAVA_ADDITIONAL_KEY",
"DIGISELLER_API_KEY",
"ANYPAY_API_KEY",
"SMTP_PASSWORD",
}
)
BOOL_ENV_KEYS = frozenset(
{"CRYPTO_PAY_TESTNET", "STARS_ENABLED", "ANYPAY_CHECK_IP", "SMTP_TLS", "SMTP_SSL"}
)
_KEY_LINE_RE = re.compile(
r"^(?P<prefix>\s*(?:export\s+)?)(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P<value>.*)$"
)
def env_file_path() -> Path:
raw = (os.getenv("ENV_FILE") or "").strip()
if raw:
return Path(raw)
return ROOT_DIR / ".env"
def env_writable(path: Path | None = None) -> bool:
p = path or env_file_path()
try:
if p.exists():
return os.access(p, os.W_OK)
parent = p.parent
return parent.exists() and os.access(parent, os.W_OK)
except OSError:
return False
def mask_secret(value: str) -> str:
v = (value or "").strip()
if not v:
return ""
if len(v) <= 6:
return "••••••"
return f"{v[:3]}{v[-4:]}"
def read_env_values(path: Path | None = None) -> dict[str, str]:
p = path or env_file_path()
if not p.exists():
return {}
raw = dotenv_values(p)
out: dict[str, str] = {}
for k, v in raw.items():
if k is None:
continue
out[str(k)] = "" if v is None else str(v)
return out
def _format_env_value(value: str) -> str:
"""Значение для .env без лишних кавычек, если не нужны."""
if value == "":
return ""
if any(ch in value for ch in ' \t#"\'\\$`\n\r'):
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
return value
def _write_env_inplace(path: Path, text: str) -> None:
"""Перезаписать содержимое файла без rename (Docker bind-mount .env)."""
data = text.encode("utf-8")
with open(path, "r+b") as fh:
fh.truncate(0)
fh.seek(0)
fh.write(data)
fh.flush()
os.fsync(fh.fileno())
def upsert_env_keys(
updates: dict[str, str | None],
*,
path: Path | None = None,
allowlist: tuple[str, ...] = PAYMENT_ENV_KEYS,
) -> Path:
"""Записать ключи в .env и в os.environ. None / пустая строка удаляет ключ.
Пишем in-place: docker volume mount файла не позволяет atomic rename
(Errno 16 Device or resource busy).
"""
p = path or env_file_path()
allowed = frozenset(allowlist)
filtered = {k: v for k, v in updates.items() if k in allowed}
if not filtered:
return p
if not p.exists():
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("", encoding="utf-8")
raw = p.read_text(encoding="utf-8")
lines = raw.splitlines(keepends=True)
if lines and not lines[-1].endswith(("\n", "\r")):
# нормализуем конец файла при дальнейшей склейке
pass
pending = dict(filtered)
new_lines: list[str] = []
for line in lines:
stripped = line.lstrip()
if not stripped or stripped.startswith("#"):
new_lines.append(line)
continue
m = _KEY_LINE_RE.match(line.rstrip("\r\n"))
if not m:
new_lines.append(line)
continue
key = m.group("key")
if key not in pending:
new_lines.append(line)
continue
value = pending.pop(key)
ending = "\n" if line.endswith("\n") else ("\r\n" if line.endswith("\r\n") else "\n")
if value is None or value == "":
os.environ.pop(key, None)
continue
prefix = m.group("prefix") or ""
new_lines.append(f"{prefix}{key}={_format_env_value(value)}{ending}")
os.environ[key] = value
for key, value in pending.items():
if value is None or value == "":
os.environ.pop(key, None)
continue
if new_lines and not new_lines[-1].endswith(("\n", "\r")):
new_lines[-1] = new_lines[-1] + "\n"
new_lines.append(f"{key}={_format_env_value(value)}\n")
os.environ[key] = value
text = "".join(new_lines)
if text and not text.endswith("\n"):
text += "\n"
_write_env_inplace(p, text)
return p
+355
View File
@@ -0,0 +1,355 @@
"""Ежемесячная оплата доп. трафика и устройств."""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from aiogram import Bot
from remnawave import RemnawaveSDK
from remnawave.models import UpdateUserRequestDto
from services.db import BotUser, Database
from services.devices import EXTRA_DEVICE_PRICE_RUB, allowed_devices
from services.mailer import notify_important
from config import get_settings
from services.remnawave_users import find_panel_user, sync_device_policy
from services.traffic import (
PACK_GB,
PACK_PRICE_RUB,
gb_to_bytes,
ensure_base_limit_bytes,
)
logger = logging.getLogger(__name__)
BILLING_PERIOD = timedelta(days=30)
def _utc(dt: datetime | None) -> datetime | None:
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def has_extras_subscription(user: BotUser) -> bool:
return user.extra_devices > 0 or user.extra_traffic_packs > 0
def monthly_extras_fee_rub(*, extra_devices: int, extra_traffic_packs: int) -> float:
return max(0, int(extra_devices)) * EXTRA_DEVICE_PRICE_RUB + max(
0, int(extra_traffic_packs)
) * PACK_PRICE_RUB
def effective_extra_devices(user: BotUser) -> int:
if not has_extras_subscription(user):
return 0
if not user.extras_active:
return 0
return max(0, user.extra_devices)
def effective_extra_traffic_packs(user: BotUser) -> int:
if not has_extras_subscription(user):
return 0
if not user.extras_active:
return 0
return max(0, user.extra_traffic_packs)
def extras_monthly_fee(user: BotUser) -> float:
if not has_extras_subscription(user):
return 0.0
return monthly_extras_fee_rub(
extra_devices=user.extra_devices,
extra_traffic_packs=user.extra_traffic_packs,
)
def format_monthly_suffix() -> str:
return "/мес"
def extras_billing_line(user: BotUser) -> str:
if not has_extras_subscription(user):
return ""
fee = int(extras_monthly_fee(user))
parts: list[str] = []
if user.extra_traffic_packs:
parts.append(f"+{user.extra_traffic_packs}×500 ГБ")
if user.extra_devices:
parts.append(f"+{user.extra_devices} устр.")
sub = ", ".join(parts)
nxt = _utc(user.extras_next_billing_at)
nxt_s = nxt.strftime("%d.%m.%Y") if nxt else ""
if not user.extras_active:
return (
f"\n⚠️ <b>Доп. опции приостановлены</b> (не оплачен период).\n"
f"Подписка: {sub} · <b>{fee} ₽/мес</b>\n"
f"Пополни баланс минимум на <b>{fee} ₽</b> — списание при открытии кабинета "
f"или автоматически раз в сутки."
)
return (
f"\n📅 Доп. опции: {sub} · <b>{fee} ₽/мес</b>\n"
f"Следующее списание: <b>{nxt_s}</b>"
)
def extras_as_dict(user: BotUser) -> dict:
fee = extras_monthly_fee(user)
nxt = _utc(user.extras_next_billing_at)
return {
"active": bool(user.extras_active),
"has_subscription": has_extras_subscription(user),
"extra_traffic_packs": user.extra_traffic_packs,
"extra_devices": user.extra_devices,
"monthly_fee": fee,
"monthly_fee_label": f"{int(fee)} ₽/мес" if fee else "0 ₽/мес",
"next_billing_at": nxt.isoformat() if nxt else None,
"next_billing_label": nxt.strftime("%d.%m.%Y") if nxt else None,
"slot_price_monthly": EXTRA_DEVICE_PRICE_RUB,
"pack_price_monthly": PACK_PRICE_RUB,
"pack_gb": PACK_GB,
"warning": (
"Доп. трафик и устройства оплачиваются ежемесячно с баланса. "
"При нехватке средств доп. лимиты отключаются до оплаты."
),
}
@dataclass(slots=True)
class ExtrasBillingResult:
charged: bool = False
suspended: bool = False
reactivated: bool = False
amount: float = 0.0
message: str = ""
async def apply_panel_extras(
sdk: RemnawaveSDK,
db: Database,
*,
telegram_id: int,
login: str | None = None,
) -> None:
"""Привести лимиты панели в соответствие с оплаченными доп. опциями."""
from services.traffic import BASE_TRAFFIC_GB
user = db.get_user(telegram_id)
if user is None:
return
panel_user = await find_panel_user(sdk, telegram_id, login=login or user.login)
if panel_user is None:
return
subscribed_packs = max(0, user.extra_traffic_packs)
panel_limit = float(panel_user.traffic_limit_bytes or 0)
base_floor = gb_to_bytes(BASE_TRAFFIC_GB)
if subscribed_packs > 0:
inferred_base = panel_limit - gb_to_bytes(PACK_GB * subscribed_packs)
base_bytes = ensure_base_limit_bytes(max(inferred_base, base_floor))
if user.extras_active:
target_limit = base_bytes + gb_to_bytes(PACK_GB * subscribed_packs)
else:
target_limit = base_bytes
else:
target_limit = ensure_base_limit_bytes(panel_limit)
try:
current = float(panel_user.traffic_limit_bytes or 0)
if abs(current - target_limit) > 1:
panel_user = await sdk.users.update_user(
UpdateUserRequestDto(
uuid=panel_user.uuid,
traffic_limit_bytes=target_limit,
)
)
except Exception: # noqa: BLE001
logger.exception("apply_panel_extras traffic telegram_id=%s", telegram_id)
extra_dev = effective_extra_devices(user)
await sync_device_policy(
sdk,
panel_user,
db=db,
telegram_id=telegram_id,
extra_paid=extra_dev,
device_blocked=user.device_blocked,
)
async def _notify_user(
bot: Bot | None,
telegram_id: int,
text: str,
*,
db: Database | None = None,
subject: str = "VPN Service",
) -> None:
if db is not None:
try:
settings = get_settings(require_bot_token=False)
except SystemExit:
settings = None
if settings is not None:
await notify_important(
db=db,
settings=settings,
telegram_id=telegram_id,
html=text,
subject=subject,
bot=bot,
)
return
if bot is None or telegram_id <= 0:
return
try:
await bot.send_message(telegram_id, text, parse_mode="HTML")
except Exception: # noqa: BLE001
logger.warning("extras billing notify failed tg=%s", telegram_id)
async def process_user_extras_billing(
db: Database,
sdk: RemnawaveSDK,
telegram_id: int,
*,
bot: Bot | None = None,
login: str | None = None,
) -> ExtrasBillingResult:
user = db.get_user(telegram_id)
if user is None or not has_extras_subscription(user):
return ExtrasBillingResult()
now = datetime.now(timezone.utc)
fee = extras_monthly_fee(user)
next_at = _utc(user.extras_next_billing_at)
if not user.extras_active:
if user.balance < fee:
return ExtrasBillingResult()
ok, _bal = db.try_charge(
telegram_id,
fee,
kind="extras_renewal",
meta=f"extras:reactivate:{user.extra_devices}d+{user.extra_traffic_packs}t",
)
if not ok:
return ExtrasBillingResult()
anchor = now if not next_at or next_at <= now else next_at
new_next = anchor + BILLING_PERIOD
db.set_extras_billing(telegram_id, next_billing_at=new_next, active=True)
await apply_panel_extras(sdk, db, telegram_id=telegram_id, login=login)
await _notify_user(
bot,
telegram_id,
(
"<b>✅ Доп. опции снова активны</b>\n\n"
f"Списано <b>{int(fee)} ₽</b> за месяц.\n"
f"Следующее списание: <b>{new_next.strftime('%d.%m.%Y')}</b>"
),
db=db,
subject="Доп. опции активны — VPN Service",
)
return ExtrasBillingResult(
charged=True, reactivated=True, amount=fee, message="reactivated"
)
if not next_at or next_at > now:
return ExtrasBillingResult()
ok, _bal = db.try_charge(
telegram_id,
fee,
kind="extras_renewal",
meta=f"extras:{user.extra_devices}d+{user.extra_traffic_packs}t",
)
if not ok:
db.set_extras_active(telegram_id, False)
await apply_panel_extras(sdk, db, telegram_id=telegram_id, login=login)
await _notify_user(
bot,
telegram_id,
(
"<b>⚠️ Доп. опции приостановлены</b>\n\n"
f"На балансе не хватает <b>{int(fee)} ₽</b> для продления.\n"
"Пополни баланс для восстановления лимитов."
),
db=db,
subject="Доп. опции приостановлены — VPN Service",
)
return ExtrasBillingResult(suspended=True, amount=fee, message="charge_failed")
new_next = next_at + BILLING_PERIOD
if new_next <= now:
new_next = now + BILLING_PERIOD
db.set_extras_billing(telegram_id, next_billing_at=new_next, active=True)
await _notify_user(
bot,
telegram_id,
(
"<b>📅 Продление доп. опций</b>\n\n"
f"Списано <b>{int(fee)} ₽</b> за месяц.\n"
f"Следующее списание: <b>{new_next.strftime('%d.%m.%Y')}</b>"
),
db=db,
subject="Продление доп. опций — VPN Service",
)
return ExtrasBillingResult(charged=True, amount=fee, message="renewed")
def on_extras_purchased(db: Database, telegram_id: int) -> None:
"""Старт или продление учёта периода после покупки доп. опций."""
user = db.get_user(telegram_id)
if user is None or not has_extras_subscription(user):
return
if user.extras_next_billing_at is None:
db.set_extras_billing(
telegram_id,
next_billing_at=datetime.now(timezone.utc) + BILLING_PERIOD,
active=True,
)
else:
db.set_extras_active(telegram_id, True)
async def run_extras_billing_cycle(
db: Database,
sdk: RemnawaveSDK,
*,
bot: Bot | None = None,
limit: int = 200,
) -> int:
ids = db.list_users_for_extras_billing(limit=limit)
done = 0
for tid in ids:
try:
await process_user_extras_billing(db, sdk, tid, bot=bot)
done += 1
except Exception: # noqa: BLE001
logger.exception("extras billing failed tg=%s", tid)
return done
async def extras_billing_worker(
db: Database,
sdk: RemnawaveSDK,
bot: Bot,
*,
interval_sec: int = 6 * 3600,
) -> None:
while True:
try:
n = await run_extras_billing_cycle(db, sdk, bot=bot)
if n:
logger.info("extras billing processed %s users", n)
except Exception: # noqa: BLE001
logger.exception("extras billing cycle error")
await asyncio.sleep(interval_sec)
+280
View File
@@ -0,0 +1,280 @@
"""Часто задаваемые вопросы (FAQ)."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class FaqItem:
id: str
question: str
answer: str
FAQ_ITEMS: tuple[FaqItem, ...] = (
FaqItem(
id="what_is",
question="Что это за VPN?",
answer=(
"Это сервис на базе Remnawave: после оплаты ты получаешь "
"<b>ссылку подписки</b>. Её вставляешь в клиент (Hiddify, Streisand, "
"v2rayNG и др.) — и подключаешься к серверам."
),
),
FaqItem(
id="how_start",
question="Как начать пользоваться?",
answer=(
"1) Пополни баланс: Stars / криптой в боте или кодом (карта/СБП — через покупку кода)\n"
"2) Купи тариф в «Тарифы» или Mini App\n"
"3) Открой «Моя подписка» → скопируй ссылку\n"
"4) Скачай клиент → вставь ссылку подписки\n"
"5) Выбери сервер и подключись"
),
),
FaqItem(
id="clients",
question="Какой клиент скачать?",
answer=(
"В боте есть раздел <b>«Скачать клиент»</b> — топ приложений с docs.rw.\n"
"Популярные варианты:\n"
"• iOS / macOS — Streisand, Happ, Shadowrocket\n"
"• Android — v2rayNG, Hiddify, Happ\n"
"• Windows — Hiddify, FlClash\n\n"
"Главное — клиент с поддержкой subscription URL."
),
),
FaqItem(
id="sub_link",
question="Где взять ссылку подписки?",
answer=(
"«Моя подписка» в боте или главная в Mini App.\n"
"Ссылка выглядит как HTTPS URL — её целиком копируешь в клиент "
"(Add subscription / Import from URL)."
),
),
FaqItem(
id="devices",
question="Сколько устройств можно?",
answer=(
"База — <b>10 устройств</b> на подписку (в тарифе).\n"
"Доп. слот: <b>50 ₽/мес</b> за каждое (первый месяц при покупке).\n"
"Списание с баланса <b>каждые 30 дней</b> за все доп. устройства.\n"
"Если средств нет — доп. слоты отключаются до пополнения.\n"
"Если устройств больше оплаченной квоты — "
"<b>подписка отключается</b>, пока не оплатишь превышение "
"или не отвяжешь лишние устройства в клиенте."
),
),
FaqItem(
id="extras_monthly",
question="Почему списывают за доп. трафик и устройства каждый месяц?",
answer=(
"Дополнительные <b>500 ГБ</b> и <b>устройства сверх 10</b> — "
"это отдельная подписка к основному тарифу.\n\n"
"• При покупке списывается первый месяц\n"
"• Далее — автоматически с баланса раз в <b>30 дней</b>\n"
"• Сумма = (число пакетов × 50 ₽) + (число доп. устройств × 50 ₽)\n"
"• Не хватает баланса — доп. лимиты приостанавливаются, "
"основная подписка остаётся на базовых 1000 ГБ и 10 устройств"
),
),
FaqItem(
id="traffic",
question="Сколько трафика даётся?",
answer=(
"В каждом тарифе база — <b>1000 ГБ</b> (включено в тариф).\n"
"Докупка: <b>+500 ГБ = 50 ₽/мес</b> (можно несколько пакетов).\n"
"Первый месяц списывается при покупке, далее — <b>каждые 30 дней</b> "
"с баланса за все подключённые пакеты.\n"
"Если на балансе не хватает — доп. ГБ отключаются до оплаты.\n"
"Расход смотри в «Моя подписка» или Mini App."
),
),
FaqItem(
id="traffic_over",
question="Что если трафик закончится?",
answer=(
"Подключение может ограничиться, пока не докупишь пакет "
"или не начнётся новый период после продления тарифа.\n"
"Докупка трафика не продлевает срок подписки — только лимит ГБ."
),
),
FaqItem(
id="pay",
question="Как оплатить?",
answer=(
"• <b>Тарифы и услуги</b> — с баланса\n\n"
"<b>Без кода (напрямую в боте)</b>:\n"
"• <b>Telegram Stars</b> — оплата ⭐ прямо в Telegram\n"
"• <b>Crypto Bot</b> — крипта\n"
"• <b>Heleket</b> — резервная крипта\n\n"
"<b>Карта, СБП и другие фиатные способы</b> — через "
"<b>код пополнения</b>:\n"
"• На странице покупки кода — <b>более 40 способов оплаты</b>\n"
"• После оплаты введи код в «Баланс» → «Код пополнения» "
"или в Mini App\n\n"
"Пополнение — раздел «Баланс» в боте."
),
),
FaqItem(
id="stars",
question="Как пополнить через Telegram Stars?",
answer=(
"1) «Баланс» → «Telegram Stars» в боте\n"
" или в Mini App / Telegram Web — блок «Telegram Stars»\n"
"2) Выбери сумму в рублях (курс задаёт сервис)\n"
"3) Оплати инвойс Stars в Telegram\n"
"4) Баланс зачислится сразу после оплаты\n\n"
"В веб-версии Telegram удобнее кнопка оплаты по ссылке "
"или оплата прямо из Mini App."
),
),
FaqItem(
id="topup_code",
question="Как пополнить по коду?",
answer=(
"Нужен, если хочешь оплатить <b>картой, СБП</b> или другим фиатом "
"(напрямую в боте без кода — только крипта).\n\n"
"1) «Баланс» → «Код пополнения» → «Купить код» "
"или кнопка в Mini App\n"
"2) На странице оплаты — <b>более 40 способов</b>\n"
"3) Введи код в боте или на главной Mini App\n"
"4) Сумма зачислится на баланс сразу\n\n"
"Один код — одно использование."
),
),
FaqItem(
id="cards_sbp",
question="Картой или СБП — как пополнить?",
answer=(
"Купи <b>код пополнения</b> по ссылке из бота или кабинета.\n"
"На странице оплаты доступно <b>более 40 способов</b> "
"(банковские карты, СБП, электронные кошельки и др.).\n\n"
"После успешной оплаты придёт код — активируй его в боте "
"или на сайте. Дальше тарифы оплачиваешь с баланса.\n\n"
"<b>Без кода</b> напрямую в боте пополнить можно "
"только <b>криптовалютой</b>."
),
),
FaqItem(
id="lava",
question="Как пополнить через Lava?",
answer=(
"Если в меню «Баланс» есть Lava — можно попробовать этот способ.\n"
"Для <b>карты и СБП</b> основной вариант — <b>код пополнения</b> "
"(40+ способов на странице покупки кода).\n\n"
"Напрямую без кода в боте доступна только <b>крипта</b>."
),
),
FaqItem(
id="digiseller",
question="Как пополнить через Digiseller?",
answer=(
"1) «Баланс» → Digiseller → «Оплатить на Digiseller»\n"
"2) Оплати товар на сайте\n"
"3) Digiseller сам откроет страницу проверки — баланс "
"зачислится автоматически, в бот придёт уведомление\n\n"
"Код вводить не нужно. Если автозачисление не сработало — "
"в боте есть «Не зачислилось? Ввести код»."
),
),
FaqItem(
id="renew",
question="Как продлить подписку?",
answer=(
"«Моя подписка» → «Продлить с баланса» или купи тариф снова.\n"
"Дни <b>добавляются</b> к текущей дате окончания, "
"если подписка ещё активна.\n\n"
"<b>За 7 дней до конца</b> бот пришлёт напоминание раз в сутки.\n"
"Если автопродление включено и на балансе хватает на последний тариф — "
"за сутки до окончания подписка <b>продлится автоматически</b> "
"(вкл/выкл в кабинете)."
),
),
FaqItem(
id="auto_renew",
question="Автопродление подписки",
answer=(
"Если автопродление <b>включено</b>, система смотрит на "
"<b>последний купленный тариф</b> и баланс.\n\n"
"• За 7 дней до окончания — ежедневное напоминание в Telegram\n"
"• В последние 24 часа (или сразу после истечения до 3 дней) "
"при достаточном балансе — автоматическое продление с баланса\n"
"• Включить или выключить можно при регистрации и в любой момент "
"в кабинете на вкладке «Главная»\n"
"• Если средств мало или автопродление выключено — "
"пополни баланс или продли вручную в боте / Mini App"
),
),
FaqItem(
id="not_working",
question="Не подключается / не работает",
answer=(
"Проверь по шагам:\n"
"1) Подписка не истекла и трафик не на нуле\n"
"2) Ссылка в клиенте актуальная (обнови subscription)\n"
"3) Сервер онлайн — «Статус серверов»\n"
"4) Попробуй другой сервер / другой клиент\n"
"5) Выключи лишний VPN/антивирус на телефоне\n\n"
"Если не помогло — напиши админу и приложи скрин ошибки."
),
),
FaqItem(
id="slow",
question="Медленно / высокий пинг",
answer=(
"Смени сервер в клиенте (ближе к тебе или менее загруженный).\n"
"Проверь «Статус серверов» — оффлайн ноды не выбирай.\n"
"На мобильном интернете скорость зависит от оператора и Wi‑Fi."
),
),
FaqItem(
id="miniapp",
question="Зачем Mini App?",
answer=(
"Это кабинет в Telegram: баланс, подписка, трафик, тарифы, "
"клиенты и серверы в одном окне.\n"
"Сайт тоже открывается в браузере — там можно войти по логину "
"или через Telegram Login."
),
),
FaqItem(
id="refund",
question="Можно вернуть деньги?",
answer=(
"Автоматического возврата нет: доступ выдаётся сразу после оплаты.\n"
"Если была техническая ошибка (списали, а ключ не выдали) — "
"создай тикет в боте или Mini App, разберём и вернём на баланс "
"или перевыпустим."
),
),
FaqItem(
id="tickets",
question="Как связаться с поддержкой?",
answer=(
"В боте: кнопка «🎫 Тикеты» → «Новый тикет».\n"
"В Mini App: вкладка «Тикеты».\n"
"Ответ придёт в Telegram и будет виден в переписке тикета."
),
),
)
def get_faq(item_id: str) -> FaqItem | None:
for item in FAQ_ITEMS:
if item.id == item_id:
return item
return None
def faq_intro() -> str:
return (
"<b>❓ FAQ — частые вопросы</b>\n\n"
"Выбери вопрос ниже. Если не нашёл ответ — создай тикет."
)
def faq_answer_text(item: FaqItem) -> str:
return f"<b>❓ {item.question}</b>\n\n{item.answer}"
+159
View File
@@ -0,0 +1,159 @@
"""Клиент Heleket Payment API (резервная крипто-оплата)."""
from __future__ import annotations
import base64
import hashlib
import json
import logging
from dataclasses import dataclass
from typing import Any
import httpx
logger = logging.getLogger(__name__)
BASE_URL = "https://api.heleket.com/v1"
PAID_STATUSES = frozenset({"paid", "paid_over"})
class HeleketError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class HeleketInvoice:
uuid: str
order_id: str
amount: str
status: str
pay_url: str
currency: str | None = None
is_final: bool = False
def _json_body(payload: dict[str, Any]) -> str:
# Как в PHP json_encode: без пробелов, unicode as-is, слэши экранируются
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
return raw.replace("/", "\\/")
def sign_request(payload: dict[str, Any], api_key: str) -> str:
body = _json_body(payload)
encoded = base64.b64encode(body.encode("utf-8")).decode("ascii")
return hashlib.md5((encoded + api_key).encode("utf-8")).hexdigest()
def verify_webhook_sign(data: dict[str, Any], api_key: str) -> bool:
payload = dict(data)
received = str(payload.pop("sign", "") or "")
if not received:
return False
return sign_request(payload, api_key) == received
class Heleket:
def __init__(
self,
merchant_id: str,
api_key: str,
*,
callback_url: str = "",
currency: str = "RUB",
) -> None:
self.merchant_id = merchant_id.strip()
self.api_key = api_key.strip()
self.callback_url = (callback_url or "").strip().rstrip("/")
self.currency = (currency or "RUB").strip().upper() or "RUB"
@property
def enabled(self) -> bool:
return bool(self.merchant_id and self.api_key)
async def _post(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
if not self.enabled:
raise HeleketError("Heleket не настроен (HELEKET_MERCHANT_ID / HELEKET_API_KEY)")
body_obj = payload if payload is not None else {}
body = _json_body(body_obj)
headers = {
"merchant": self.merchant_id,
"sign": sign_request(body_obj, self.api_key),
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{BASE_URL}{path}",
headers=headers,
content=body.encode("utf-8"),
)
try:
data = resp.json()
except Exception as exc: # noqa: BLE001
raise HeleketError(f"HTTP {resp.status_code}: {resp.text[:300]}") from exc
state = data.get("state")
if resp.status_code >= 400 or (state not in (0, "0", None) and state != 0):
errors = data.get("errors") or data.get("message") or data
raise HeleketError(str(errors))
result = data.get("result")
if not isinstance(result, dict):
raise HeleketError(f"Неожиданный ответ: {data}")
return result
async def create_invoice(
self,
*,
amount_rub: float,
order_id: str,
description: str = "",
lifetime: int = 3600,
) -> HeleketInvoice:
payload: dict[str, Any] = {
"amount": f"{amount_rub:.2f}",
"currency": self.currency,
"order_id": order_id,
"lifetime": max(300, min(lifetime, 43200)),
"is_payment_multiple": False,
"theme": "dark",
}
if self.callback_url:
payload["url_callback"] = self.callback_url
if description:
payload["additional_data"] = description[:255]
result = await self._post("/payment", payload)
return self._parse(result)
async def get_invoice(
self,
*,
order_id: str | None = None,
uuid: str | None = None,
) -> HeleketInvoice | None:
payload: dict[str, Any] = {}
if order_id:
payload["order_id"] = order_id
if uuid:
payload["uuid"] = uuid
if not payload:
raise HeleketError("Нужен order_id или uuid")
try:
result = await self._post("/payment/info", payload)
except HeleketError:
return None
return self._parse(result)
@staticmethod
def is_paid(status: str) -> bool:
return status in PAID_STATUSES
@staticmethod
def _parse(raw: dict[str, Any]) -> HeleketInvoice:
status = str(raw.get("payment_status") or raw.get("status") or "")
return HeleketInvoice(
uuid=str(raw.get("uuid") or ""),
order_id=str(raw.get("order_id") or ""),
amount=str(raw.get("amount") or ""),
status=status,
pay_url=str(raw.get("url") or ""),
currency=raw.get("currency"),
is_final=bool(raw.get("is_final")),
)
+200
View File
@@ -0,0 +1,200 @@
"""Клиент Lava.ru Business API (карта / СБП)."""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
from dataclasses import dataclass
from typing import Any
import httpx
logger = logging.getLogger(__name__)
API_BASE = "https://api.lava.ru"
PAID_STATUSES = frozenset({"success", "paid"})
class LavaError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class LavaInvoice:
invoice_id: str
order_id: str
amount: float
status: str
pay_url: str
expired: str | None = None
def _json_body(payload: dict[str, Any]) -> bytes:
# Порядок ключей и separators критичны для Signature
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
def sign_request(payload: dict[str, Any], secret_key: str) -> str:
return hmac.new(
secret_key.encode("utf-8"),
_json_body(payload),
hashlib.sha256,
).hexdigest()
def verify_webhook(
*,
data: dict[str, Any],
raw_body: bytes,
authorization: str | None,
additional_key: str,
) -> bool:
"""Проверка подписи webhook (два формата Lava)."""
key = (additional_key or "").strip()
if not key:
# без доп. ключа принимаем только если нет подписи вообще (нежелательно)
return not data.get("sign") and not (authorization or "").strip()
# Новый формат SDK: HMAC SHA256(raw body) в Authorization
auth = (authorization or "").strip()
if auth:
expected = hmac.new(key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
if hmac.compare_digest(auth.lower(), expected.lower()):
return True
# Классический формат: md5(invoice_id:amount:pay_time:secret_key_2)
received = str(data.get("sign") or "").strip()
if received:
invoice_id = str(data.get("invoice_id") or "")
amount = data.get("amount")
pay_time = data.get("pay_time")
# amount иногда float — приводим как в доке (как пришло / без лишних нулей)
amount_s = str(amount)
raw = f"{invoice_id}:{amount_s}:{pay_time}:{key}"
expected_md5 = hashlib.md5(raw.encode("utf-8")).hexdigest()
if hmac.compare_digest(received.lower(), expected_md5.lower()):
return True
return False
class Lava:
def __init__(
self,
shop_id: str,
secret_key: str,
*,
additional_key: str = "",
hook_url: str = "",
success_url: str = "",
fail_url: str = "",
expire_minutes: int = 60,
) -> None:
self.shop_id = (shop_id or "").strip()
self.secret_key = (secret_key or "").strip()
self.additional_key = (additional_key or "").strip()
self.hook_url = (hook_url or "").strip().rstrip("/")
self.success_url = (success_url or "").strip()
self.fail_url = (fail_url or "").strip()
self.expire_minutes = max(5, min(int(expire_minutes or 60), 5 * 24 * 60))
@property
def enabled(self) -> bool:
return bool(self.shop_id and self.secret_key)
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
if not self.enabled:
raise LavaError("Lava не настроен (LAVA_SHOP_ID / LAVA_SECRET_KEY)")
body = _json_body(payload)
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"Signature": sign_request(payload, self.secret_key),
}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{API_BASE}{path}",
headers=headers,
content=body,
)
try:
data = resp.json()
except Exception as exc: # noqa: BLE001
raise LavaError(f"HTTP {resp.status_code}: {resp.text[:300]}") from exc
if not isinstance(data, dict):
raise LavaError(f"Неожиданный ответ: {data}")
if resp.status_code >= 400 or not data.get("status_check", True):
err = data.get("error") or data.get("message") or data
raise LavaError(str(err))
result = data.get("data")
if not isinstance(result, dict):
raise LavaError(f"Нет data в ответе: {data}")
return result
async def create_invoice(
self,
*,
amount_rub: float,
order_id: str,
comment: str = "",
custom_fields: str = "",
) -> LavaInvoice:
# Порядок ключей фиксируем — от него зависит Signature
payload: dict[str, Any] = {
"shopId": self.shop_id,
"sum": float(f"{amount_rub:.2f}"),
"orderId": order_id,
}
if comment:
payload["comment"] = comment[:255]
if custom_fields:
payload["customFields"] = custom_fields[:500]
if self.fail_url:
payload["failUrl"] = self.fail_url
if self.success_url:
payload["successUrl"] = self.success_url
if self.hook_url:
payload["hookUrl"] = self.hook_url
payload["expire"] = self.expire_minutes
result = await self._post("/business/invoice/create", payload)
return self._parse(result, order_id_fallback=order_id)
async def get_invoice(
self,
*,
order_id: str | None = None,
invoice_id: str | None = None,
) -> LavaInvoice | None:
payload: dict[str, Any] = {"shopId": self.shop_id}
if order_id:
payload["orderId"] = order_id
if invoice_id:
payload["invoiceId"] = invoice_id
if "orderId" not in payload and "invoiceId" not in payload:
raise LavaError("Нужен order_id или invoice_id")
try:
result = await self._post("/business/invoice/status", payload)
except LavaError:
return None
return self._parse(result, order_id_fallback=order_id or "")
@staticmethod
def is_paid(status: str) -> bool:
return str(status or "").lower() in PAID_STATUSES
@staticmethod
def _parse(raw: dict[str, Any], *, order_id_fallback: str = "") -> LavaInvoice:
return LavaInvoice(
invoice_id=str(raw.get("id") or raw.get("invoice_id") or ""),
order_id=str(raw.get("orderId") or raw.get("order_id") or order_id_fallback),
amount=float(raw.get("amount") or raw.get("sum") or 0),
status=str(raw.get("status") or ""),
pay_url=str(raw.get("url") or raw.get("payUrl") or ""),
expired=str(raw.get("expired") or raw.get("expire") or "") or None,
)
+782
View File
@@ -0,0 +1,782 @@
"""Юридические и служебные страницы кабинета.
Перед продом задайте LEGAL_* / BRAND_* и STATUS_PAGE_URL в env.
Документы не заменяют консультацию юриста.
"""
from __future__ import annotations
import html
import os
from datetime import date
BRAND = (os.getenv("BRAND_NAME") or "VPN Service").strip() or "VPN Service"
BRAND_SITE = (os.getenv("BRAND_SITE") or "example.com").strip() or "example.com"
SITE = (os.getenv("WEBAPP_URL") or "https://bot.example.com").rstrip("/")
SUPPORT_EMAIL = (os.getenv("LEGAL_SUPPORT_EMAIL") or "support@example.com").strip()
OPERATOR_NAME = (os.getenv("LEGAL_OPERATOR_NAME") or BRAND_SITE).strip()
OPERATOR_ADDRESS = (os.getenv("LEGAL_OPERATOR_ADDRESS") or "").strip()
EFFECTIVE = (os.getenv("LEGAL_EFFECTIVE_DATE") or date.today().isoformat()).strip()
STATUS_URL = (os.getenv("STATUS_PAGE_URL") or "https://status.example.com/").strip()
def _nav() -> str:
items = [
("/contacts", "Контакты"),
("/status", "Статус"),
("/delivery", "Доставка"),
("/refund", "Возврат"),
("/offer", "Оферта"),
("/privacy", "Персональные данные"),
("/help", "Помощь"),
("/support", "Поддержка"),
("/cookies", "Cookie"),
("/rules", "Правила сайта"),
("/vpn-usage", "Правила VPN"),
]
links = "\n ".join(
f'<a href="{href}">{html.escape(label)}</a>' for href, label in items
)
return f'<nav class="nav muted">\n {links}\n </nav>'
def _page(title: str, body: str, *, lang: str = "ru", extra_head: str = "") -> str:
title_e = html.escape(title)
addr = (
f"<br/>Address: {html.escape(OPERATOR_ADDRESS)}"
if OPERATOR_ADDRESS
else ""
)
return f"""<!DOCTYPE html>
<html lang="{lang}">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>{title_e}{html.escape(BRAND)}</title>
<style>
:root {{ color-scheme: dark; }}
body{{font-family:"Segoe UI",system-ui,sans-serif;max-width:760px;margin:32px auto;padding:0 16px 48px;
line-height:1.6;color:#e8efe9;background:#0c1210}}
a{{color:#3dcf9a}} h1{{font-size:1.55rem;margin:0 0 8px}} h2{{font-size:1.15rem;margin:28px 0 8px}}
.muted{{opacity:.72;font-size:.92rem}} .nav{{display:flex;flex-wrap:wrap;gap:10px 14px;margin:12px 0 24px}}
.card{{border:1px solid rgba(180,220,200,.12);border-radius:14px;padding:14px 16px;background:#141c18;margin:16px 0}}
ul{{padding-left:1.2rem}} li{{margin:6px 0}} .warn{{border-left:3px solid #e8c96a;padding-left:12px}}
.btn-row{{display:flex;flex-wrap:wrap;gap:10px;margin:16px 0}}
button.cookie-btn{{cursor:pointer;border:1px solid rgba(61,207,154,.35);background:#1a2820;color:#e8efe9;
border-radius:10px;padding:10px 14px;font:inherit}}
button.cookie-btn.primary{{background:#1f6b4a;border-color:#3dcf9a}}
#cookie-status{{margin-top:8px}}
</style>
{extra_head}
</head>
<body>
<p><a href="/">← Кабинет</a></p>
{_nav()}
<h1>{title_e}</h1>
<p class="muted">Актуальная версия · {html.escape(EFFECTIVE)} · {html.escape(SITE)}</p>
{body}
<div class="card muted">
<strong>{html.escape(BRAND_SITE)}</strong><br/>
Operator: {html.escape(OPERATOR_NAME)}{addr}<br/>
Support: <a href="mailto:{html.escape(SUPPORT_EMAIL)}">{html.escape(SUPPORT_EMAIL)}</a>
</div>
</body></html>"""
def html_offer() -> str:
e = html.escape
body = f"""
<p>Настоящий документ является публичной офертой и условиями использования сервиса {e(BRAND_SITE)},
подготовлен с учётом требований ЕС (права потребителей, цифровой контент, e-commerce, GDPR — где применимо)
и США (нормы FTC и штатов, включая CCPA/CPRA). Регистрация, оплата и использование сервиса означают акцепт оферты.</p>
<h2>1. Термины</h2>
<p><strong>Исполнитель</strong> — владелец сервиса {e(BRAND_SITE)}, предоставляющий доступ к VPN.
<strong>Заказчик</strong> (пользователь, потребитель) — дееспособное физическое или юридическое лицо, принявшее оферту
путём регистрации и/или оплаты. <strong>Услуги</strong> — предоставление доступа к VPN-конфигурациям и функциям кабинета
по выбранному тарифу. <strong>Цифровой контент</strong> — подписка, ссылка доступа и иные данные в электронной форме.</p>
<h2>2. Контакты исполнителя</h2>
<ul>
<li>Email: <a href="mailto:{e(SUPPORT_EMAIL)}">{e(SUPPORT_EMAIL)}</a></li>
<li>Поддержка: тикеты в кабинете; <a href="mailto:{e(SUPPORT_EMAIL)}">{e(SUPPORT_EMAIL)}</a></li>
</ul>
<p>Полный список: <a href="/contacts">Контакты</a>.</p>
<h2>3. Предмет договора</h2>
<p>Исполнитель обязуется предоставить Заказчику VPN-доступ согласно оплаченному тарифу; Заказчик обязуется
оплатить услуги по ценам на сайте на момент покупки. Услуга является цифровым контентом, не поставляемым
на материальном носителе, предоставляемым удалённо (онлайн).</p>
<p>Существенные характеристики (срок, цена, лимиты трафика и устройств, список серверов) указываются
на странице тарифа до оплаты. Дополнительные условия — в
<a href="/vpn-usage">правилах использования VPN</a> и <a href="/rules">правилах сайта</a>.</p>
<h2>4. Заключение договора и акцепт</h2>
<p>Договор считается заключённым с момента:</p>
<ul>
<li>успешной оплаты (списание с баланса или завершение платежа через провайдера);</li>
<li>подтверждения согласия с офертой, политикой персональных данных, правилами сайта и VPN,
условиями доставки и возврата — обязательными отметками при регистрации и/или покупке.</li>
</ul>
<p>Публикация тарифов — приглашение делать оферты; акцепт — оплата выбранного тарифа Заказчиком.</p>
<h2>5. Оказание услуги и «доставка»</h2>
<p>Заказчик регистрируется, при необходимости пополняет баланс и покупает подписку в кабинете.
Доступ предоставляется сразу после успешной оплаты: конфигурация и ссылка подписки отображаются в кабинете.
Отдельная плата за доставку не взимается. Подробнее: <a href="/delivery">Условия доставки</a>.</p>
<p>Срок подписки считается по тарифу (календарные дни/месяцы), если не включён режим заморозки до активации —
тогда срок начинается с момента активации, указанного на сайте.</p>
<h2>6. Цена и оплата</h2>
<p>Цены указываются на сайте до оформления заказа. Оплата — доступными в кабинете способами;
обработку платежей ведут платёжные провайдеры по своим правилам. Исполнитель не хранит полные данные банковских карт.</p>
<p>Валюта отображения — на усмотрение Исполнителя; фактическая сумма списания может зависеть от курса и комиссий.
НДС: если Исполнитель не является налогоплательщиком в соответствующей юрисдикции, цена может указываться
без отдельной налоговой строки; для потребителей ЕС налоговые обязанности определяются законом и статусом Исполнителя.</p>
<h2>7. Право на отказ (ЕС) и немедленное предоставление цифрового контента</h2>
<p>Для потребителей в ЕС действует право отказаться от дистанционного договора в течение 14 календарных дней
без указания причин (Директива 2011/83/EU), если ниже не предусмотрено иное.</p>
<p>VPN-доступ — цифровой контент не на материальном носителе. При оформлении заказа Заказчик:</p>
<ul>
<li>запрашивает немедленное начало оказания услуги после оплаты;</li>
<li>подтверждает ознакомление с условиями и понимает: после начала оказания и выдачи доступа
14-дневное право отказа может быть утрачено в объёме фактически оказанной услуги
(ст. 16(m) Директивы о правах потребителей / национальные имплементации).</li>
</ul>
<p>Возвраты после начала использования — по <a href="/refund">условиям возврата</a>, в пределах императивных норм ЕС.</p>
<h2>8. Возврат и отмена (США и общие положения)</h2>
<p>Для потребителей в США применяются условия возврата и обязательные нормы штата проживания
(включая права жителей Калифорнии по CCPA/CPRA — см. политику персональных данных).</p>
<p>Цифровые услуги с мгновенной доставкой, как правило, не возвращаются после фактического подключения и использования,
кроме случаев, прямо указанных в условиях возврата, ошибочного двойного списания или когда это обязательно по закону.</p>
<h2>9. Правовая рамка и обязанности Заказчика</h2>
<p>Исполнитель определяет допустимое использование услуг на основе права ЕС и США
(см. <a href="/vpn-usage">правила VPN</a>). Законодательство стран с авторитарным или цензурным регулированием интернета
не является референсной рамкой настоящих условий.</p>
<p>Заказчик обязуется:</p>
<ul>
<li>использовать VPN законно по применимому праву ЕС/США и настоящим документам;</li>
<li>не передавать доступ третьим лицам в коммерческих целях без согласия Исполнителя;</li>
<li>соблюдать лимиты устройств и условия тарифа;</li>
<li>не совершать действий, указанных в правилах сайта и VPN (взлом, мошенничество, CSAM, обход санкций OFAC/ЕС и т.п.).</li>
</ul>
<p>Заказчик самостоятельно оценивает риски использования VPN в стране пребывания.
Исполнитель не гарантирует законность VPN во всех юрисдикциях мира.</p>
<h2>10. Обработка персональных данных</h2>
<p>Обработка данных — по <a href="/privacy">Политике обработки персональных данных</a>
(GDPR, ePrivacy, CCPA/CPRA — где применимо). Регистрация и оплата подтверждают ознакомление с политикой.
Права субъекта данных можно реализовать через профиль и поддержку.</p>
<h2>11. Качество услуги и отказ от гарантий</h2>
<p>Исполнитель стремится поддерживать работоспособность VPN, но услуга предоставляется «как есть» и «как доступна»
в пределах, допускаемых императивным правом ЕС и США.</p>
<p>Не гарантируются: непрерывная доступность, конкретная скорость, доступность отдельных сайтов или сервисов,
обход всех блокировок операторов и государств. Обслуживание, сбои провайдеров и форс-мажор могут влиять на доступ.</p>
<p>Для потребителей ЕС: ничто в оферте не ограничивает законные гарантии соответствия цифрового контента
по Директиве (EU) 2019/770 в объёме, применимом к характеру услуги.</p>
<h2>12. Ограничение ответственности</h2>
<p>Исполнитель не отвечает за: действия Заказчика и третьих лиц; содержание пользовательского трафика;
блокировки VPN на уровне государств и провайдеров; косвенные убытки, упущенную выгоду, потерю данных —
в максимальной мере, допускаемой применимым правом.</p>
<p>Совокупная ответственность Исполнителя ограничена суммой, фактически уплаченной Заказчиком
за спорную подписку за последние 12 месяцев, если иное не требуется императивными нормами защиты потребителей ЕС/США.</p>
<p>Ограничения не применяются к ответственности за умысел, грубую неосторожность, вред жизни и здоровью
и иным случаям, когда ответственность нельзя исключить законом.</p>
<h2>13. Санкции и экспортный контроль</h2>
<p>Услуги не предоставляются лицам и организациям, подпадающим под санкции США (OFAC), ЕС или иные обязательные режимы.
Заказчик подтверждает отсутствие таких ограничений. При нарушении Исполнитель может немедленно расторгнуть договор.</p>
<h2>14. Приостановка и прекращение</h2>
<p>Исполнитель может приостановить или прекратить доступ за нарушение оферты, правил, права ЕС/США,
злоупотребление ресурсами, неоплату или по запросу платёжного провайдера.
Постоянная блокировка за серьёзные нарушения — по <a href="/rules">правилам сайта</a>.</p>
<p>Заказчик может прекратить использование в любое время; удаление аккаунта — через профиль.
Неиспользованный предоплаченный период не возвращается, кроме случаев по условиям возврата и императивному праву.</p>
<h2>15. Изменение условий</h2>
<p>Исполнитель может изменять оферту, цены и тарифы. Актуальная версия публикуется на этой странице.
О существенных изменениях для активных подписок может быть направлено уведомление (сайт, email).
Продолжение использования после вступления изменений в силу означает согласие, если иное не запрещено законом.
Для потребителей ЕС: изменения не ухудшают уже оплаченные права без согласия в течение оплаченного тарифа.</p>
<h2>16. Применимое право и споры</h2>
<p>Договор регулируется правом, определяемым по императивным коллизионным нормам защиты потребителей:
для потребителей в ЕС/ЕЭЗ сохраняются обязательные нормы страны обычного проживания, если они более благоприятны.</p>
<p>Для потребителей в США — обязательные нормы штата проживания в требуемом законом объёме.</p>
<p>Споры решаются переговорами и обращением в поддержку. При отсутствии согласия:</p>
<ul>
<li>ЕС: потребитель может использовать платформу ODR Еврокомиссии и компетентные органы защиты потребителей;</li>
<li>США: споры могут рассматриваться судами штата проживания или иными органами по местному праву.</li>
</ul>
<p>Ничто в этом разделе не лишает потребителя права на судебную защиту по императивным нормам его страны.</p>
<h2>17. Заключительные положения</h2>
<p>Оферта действует с момента публикации на сайте. Недействительность отдельного положения не влечёт
недействительности оферты в целом. Неотъемлемые документы:
<a href="/rules">правила сайта</a>, <a href="/vpn-usage">правила VPN</a>,
<a href="/privacy">политика персональных данных</a>, <a href="/delivery">доставка</a>, <a href="/refund">возврат</a>.</p>
"""
return _page("Договор оферты", body)
def html_terms() -> str:
"""Алиас: на example.com Terms of Service = Offer Agreement."""
return html_offer()
def html_privacy() -> str:
e = html.escape
body = f"""
<p>Актуальная версия. Политика подготовлена с учётом требований ЕС (GDPR, ePrivacy)
и США (штатные законы о приватности, включая CCPA/CPRA, рекомендации FTC).
По вопросам обработки данных — <a href="/contacts">контакты</a>.</p>
<h2>1. Общие положения</h2>
<p>Сервис {e(BRAND_SITE)} («мы», «сервис», «оператор») уважает вашу конфиденциальность.
Политика описывает, какие персональные данные мы собираем, на каком основании обрабатываем,
кому передаём, как долго храним и какие у вас права.</p>
<p>Политика применяется при использовании сайта, кабинета, VPN и иных услуг {e(BRAND_SITE)}.
Используя сервис, вы подтверждаете ознакомление с документом.</p>
<h2>2. Оператор и контакты</h2>
<p>Оператор (контролёр данных в смысле GDPR) — владелец сервиса {e(BRAND_SITE)}.</p>
<ul>
<li>Email по вопросам приватности: <a href="mailto:{e(SUPPORT_EMAIL)}">{e(SUPPORT_EMAIL)}</a></li>
<li>Поддержка: тикеты в кабинете; <a href="mailto:{e(SUPPORT_EMAIL)}">{e(SUPPORT_EMAIL)}</a></li>
</ul>
<p>Отдельный DPO не назначен; запросы по персональным данным принимаются по указанным каналам.</p>
<h2>3. Какие данные обрабатываем</h2>
<ul>
<li><strong>Идентификация и контакты:</strong> email, имя или псевдоним, идентификатор аккаунта;</li>
<li><strong>Учётные данные:</strong> хеш пароля, данные 2FA (если включена), привязка Telegram (если используется);</li>
<li><strong>Договор и платежи:</strong> баланс, история пополнений и списаний, тариф и подписка
(без полных данных карт — их обрабатывают платёжные провайдеры);</li>
<li><strong>VPN-услуги:</strong> сведения о купленных конфигурациях, сроках, выбранных серверах;
мы намеренно не анализируем и не храним содержимое вашего VPN-трафика;</li>
<li><strong>Поддержка:</strong> тема и текст тикетов, вложения, которые вы отправляете;</li>
<li><strong>Реферальная программа</strong> (при участии): связь с пригласившим, начисления;</li>
<li><strong>Технические данные:</strong> IP при доступе к сайту и API, тип браузера и ОС, cookies,
local storage, журналы ошибок и безопасности (в ограниченном объёме).</li>
</ul>
<p>Мы не собираем особые категории данных (здоровье, биометрия, политические взгляды и т.п.);
сервис не предназначен для детей младше 16 лет (см. раздел 12).</p>
<h2>4. Источники данных</h2>
<ul>
<li>напрямую от вас (регистрация, оплата, тикеты, настройки профиля);</li>
<li>автоматически при посещении сайта (технические логи, cookies);</li>
<li>от платёжных и иных подрядчиков — в объёме, необходимом для завершения транзакции.</li>
</ul>
<h2>5. Cookies и аналогичные технологии (ePrivacy / GDPR)</h2>
<p>Мы используем cookies и local storage браузера. По назначению они делятся на:</p>
<ul>
<li><strong>Строго необходимые</strong> — сессия входа, безопасность, работа кабинета.
Основание: исполнение договора и законный интерес (ст. 6(1)(b), (f) GDPR); согласие не требуется.</li>
<li><strong>Функциональные / предпочтения</strong> — запоминание выбора в баннере cookie (<code>cookie_consent</code>).
Основание: согласие (ст. 6(1)(a) GDPR) через «Принять все» или «Только необходимые».</li>
<li><strong>Опциональные (третьи стороны)</strong> — виджет чата поддержки (если включён на сайте).
Загружается только после «Принять все»; может устанавливать cookies третьей стороны и передавать данные в США.
Отказ — «Только необходимые» или <a href="/cookies">Настройки cookie</a>.</li>
</ul>
<p>Мы не используем стороннюю рекламу и профилирующие cookies для межсайтового трекинга.
Вы можете удалить или заблокировать cookies в настройках браузера; отказ от необходимых может сделать вход невозможным.</p>
<p>Срок хранения cookie согласия — до 12 месяцев, если иное не указано в браузере.</p>
<h2>6. Цели и правовые основания (GDPR)</h2>
<ul>
<li><strong>Исполнение договора</strong> (ст. 6(1)(b)) — регистрация, выдача VPN, списания, поддержка по запросу;</li>
<li><strong>Юридическая обязанность</strong> (ст. 6(1)(c)) — учёт и налоги, ответы на обязательные запросы органов ЕС/США;</li>
<li><strong>Законный интерес</strong> (ст. 6(1)(f)) — безопасность, предотвращение мошенничества и злоупотреблений,
защита прав в спорах, стабильность сайта (без избыточного профилирования);</li>
<li><strong>Согласие</strong> (ст. 6(1)(a)) — опциональные cookies, маркетинг (если введётся и только с отдельным согласием).
Согласие можно отозвать без ущерба для законной обработки до отзыва.</li>
</ul>
<h2>7. VPN и минимизация данных</h2>
<p>Политика конфиденциальности VPN дополняется <a href="/vpn-usage">правилами использования VPN</a>.
Мы соблюдаем принцип минимизации: не ведём целевой мониторинг содержимого трафика.
Технические метаданные (например, объём трафика на панелях) могут обрабатываться для биллинга,
лимитов тарифа и предотвращения злоупотреблений — без привязки к содержимому посещённых сайтов.</p>
<h2>8. Сроки хранения</h2>
<ul>
<li>данные аккаунта и подписки — пока аккаунт активен и разумный срок после удаления
(бэкапы и споры), обычно не более 90 дней после окончательного удаления, если закон не требует дольше;</li>
<li>финансовые записи — сроки налогового и бухгалтерского учёта (часто до 6–7 лет);</li>
<li>тикеты поддержки — до закрытия вопроса и до 3 лет истории обращений, если вы не запросите более раннее удаление;</li>
<li>технические логи серверов — обычно до 30–90 дней, если не требуется дольше для расследования инцидентов.</li>
</ul>
<p>По истечении сроков данные удаляются или обезличиваются.</p>
<h2>9. Передачи и обработчики</h2>
<p>Мы не продаём персональные данные и не передаём их за денежное вознаграждение
(включая «sale»/«sharing» по CCPA/CPRA для поведенческой рекламы).</p>
<p>Данные могут передаваться обработчикам только для оказания услуги: платёжные системы, хостинг и ЦОД,
сервисы email/уведомлений, виджет чата (при согласии «Принять все»), подрядчики мониторинга
(без доступа к содержимому VPN-трафика). С ними заключаются DPA или стандартные договорные условия в объёме GDPR.</p>
<p>Раскрытие государственным органам — только по действительному и обязательному запросу
по праву ЕС, США или иной юрисдикции, обязательной для нас.</p>
<h2>10. Международные передачи</h2>
<p>Серверы и подрядчики могут находиться вне вашей страны, в том числе вне ЕЭЗ.
При передаче из ЕС/ЕЭЗ применяются меры главы V GDPR: SCC, решения об адекватности или иные законные механизмы.</p>
<h2>11. Меры безопасности</h2>
<p>Шифрование паролей, ограничение доступа к БД, HTTPS, разграничение прав администраторов, резервные копии.
Ни один способ передачи или хранения в интернете не гарантирует абсолютную безопасность.</p>
<h2>12. Дети</h2>
<p>Сервис не предназначен для лиц младше 16 лет (в отдельных штатах США — младше 13 без согласия родителей).
Мы сознательно не собираем данные детей. При обнаружении такого аккаунта можем удалить его и связанные данные.</p>
<h2>13. Автоматизированные решения</h2>
<p>Мы не принимаем решений о вас исключительно на основе автоматизированной обработки,
порождающих правовые последствия или существенно затрагивающих вас (ст. 22 GDPR).
Ограничения аккаунта за злоупотребления принимаются администрацией с учётом обстоятельств.</p>
<h2>14. Ваши права</h2>
<p>В зависимости от места проживания вы можете иметь права на доступ, исправление, удаление («право быть забытым»),
ограничение обработки, переносимость, возражение, отзыв согласия, жалобу в надзорный орган ЕС/ЕЭЗ
или компетентный орган штата США.</p>
<p>Как реализовать: экспорт данных и удаление аккаунта — в профиле кабинета;
иные запросы — тикет в поддержку или email <a href="mailto:{e(SUPPORT_EMAIL)}">{e(SUPPORT_EMAIL)}</a>.
Срок ответа — до 30 дней (GDPR) или сроки штатного права США (например, 45 дней по CCPA с возможным продлением).
Мы можем запросить подтверждение личности.</p>
<p>Жители Калифорнии: мы не «продаём» и не «расшариваем» персональные данные для кросс-контекстной поведенческой рекламы.
Права знать категории данных, запросить удаление и исправление — по CCPA/CPRA. Дискриминации за реализацию прав нет.</p>
<h2>15. Изменения политики</h2>
<p>Актуальная версия всегда на этой странице; о существенных изменениях можем уведомить через сайт или email.</p>
<h2>16. Контакты</h2>
<p>По всем вопросам приватности: <a href="mailto:{e(SUPPORT_EMAIL)}">{e(SUPPORT_EMAIL)}</a>,
раздел <a href="/support">Поддержка</a>, <a href="/contacts">Контакты</a>.</p>
"""
return _page("Политика обработки персональных данных", body)
def html_site_rules() -> str:
e = html.escape
body = f"""
<p>Использование сервиса означает принятие настоящих правил. Референсная рамка допустимого поведения —
право Европейского Союза и Соединённых Штатов Америки
(см. также <a href="/vpn-usage">правила VPN</a> и <a href="/offer">оферту</a>).</p>
<h2>1. Общие положения</h2>
<p>Сервис {e(BRAND_SITE)} предоставляет доступ к VPN и связанным цифровым услугам.
Регистрируясь, вы соглашаетесь с правилами, <a href="/privacy">политикой персональных данных</a> (GDPR, CCPA/CPRA)
и <a href="/offer">офертой</a>. Подробные условия VPN — в <a href="/vpn-usage">правилах использования VPN</a>.</p>
<h2>2. Регистрация и аккаунт</h2>
<p>Для покупки необходима регистрация. Укажите действующий email и храните пароль в безопасности.
Вы отвечаете за действия с вашего аккаунта. Сервис не предназначен для лиц младше 16 лет
(13 в отдельных штатах США без согласия родителей).</p>
<h2>3. Оплата и использование</h2>
<p>Оплата — через баланс кабинета и платёжных провайдеров. Конфигурации выдаются в кабинете.
Передача доступа третьим лицам в коммерческих целях без согласия сервиса запрещена.</p>
<h2>4. Лимит устройств</h2>
<p>Одна подписка — не более 10 одновременных устройств, если тариф не указывает иное.
Для большего числа устройств — отдельная подписка. Превышение лимита — нарушение; возможна приостановка.</p>
<h2>5. Запрещённое использование</h2>
<p>Запрещено использовать сервис для деятельности, противоречащей праву ЕС/США, включая
(но не ограничиваясь): несанкционированный доступ к системам, вредоносное ПО, DDoS, спам, мошенничество,
CSAM, обход санкций OFAC/ЕС, систематическое пиратство. Полный список — в <a href="/vpn-usage">правилах VPN</a>.</p>
<p>Сервис не обязан ограничивать доступ к ресурсам только потому, что они заблокированы в отдельных странах
с авторитарным регулированием сети, если иное не требуется применимым правом ЕС/США.</p>
<h2>6. Поддержка и споры</h2>
<p>Вопросы — через <a href="/support">Поддержку</a> или <a href="/contacts">контакты</a>.
Потребители ЕС: ODR. Споры — по <a href="/offer">оферте</a>, раздел 16.</p>
<h2>7. Возвраты, вред и блокировки</h2>
<p>Возвраты — по <a href="/refund">условиям возврата</a> и императивному праву ЕС/США.
При существенном вреде сервису возврат может не выдаваться.
За серьёзные нарушения возможна постоянная блокировка без восстановления доступа.</p>
<h2>8. Блокировки VPN и простой серверов</h2>
<p>Государственные или провайдерские блокировки VPN — риск пользователя, а не основание для автоматического возврата
(см. <a href="/refund">возврат</a>, раздел 7). Возврат возможен только при длительном простое по вине сервиса
(&gt;3 подряд календарных дней без замены сервера) — см. страницу возврата.</p>
<h2>9. Изменение правил</h2>
<p>Актуальная версия — на этой странице. О существенных изменениях можем сообщить через сайт или email.
Для потребителей ЕС: изменения не ухудшают уже оплаченный период без согласия.</p>
"""
return _page("Правила сайта", body)
def html_aup() -> str:
return html_vpn_usage()
def html_vpn_usage() -> str:
e = html.escape
body = f"""
<p>Документ дополняет <a href="/rules">правила сайта</a> и <a href="/offer">договор оферты</a>.
Используя VPN-услуги, вы подтверждаете ознакомление со следующим.
Политика допустимого использования VPN формируется со ссылкой на право ЕС и США.</p>
<h2>1. Назначение сервиса</h2>
<p>Сервис {e(BRAND_SITE)} предоставляет доступ к зашифрованному VPN-соединению для личного законного использования:
защита приватности, безопасность данных в публичных и домашних сетях.
Сервис не предназначен для нарушения закона и не гарантирует доступ к конкретным ресурсам
при изменении политик операторов или сетевых ограничений.</p>
<h2>2. Тарифы и трафик: условно безлимитный</h2>
<p>Если в описании тарифа указано «безлимит» или сходная формулировка, это означает условно безлимитное использование
в рамках разумного личного потребления (веб, мессенджеры, стриминг в обычном качестве и т.п.).</p>
<p>Такой режим не является абсолютным неограниченным объёмом: существуют физические лимиты канала,
политики ЦОД и требования стабильной работы сети для всех клиентов.</p>
<h2>3. Fair use и распределение ресурсов</h2>
<p>VPN-инфраструктура рассчитана на совместное использование многими пользователями.
Для стабильной скорости и доступности сервис следует принципу справедливого распределения ресурсов (fair use):
избыточная, нетипичная или монополизирующая нагрузка на каналы и узлы не допускается.</p>
<h2>4. Меры при злоупотреблении трафиком или нагрузкой</h2>
<p>При обнаружении злоупотребления администрация может по своему усмотрению:</p>
<ul>
<li>установить индивидуальные лимиты скорости и/или объёма трафика на аккаунт или соединение;</li>
<li>временно ограничить или приостановить VPN-доступ до выяснения обстоятельств;</li>
<li>при серьёзных или повторных нарушениях — прекратить оказание услуги
в соответствии с правилами сайта и офертой.</li>
</ul>
<p>Цель мер — справедливые условия для всех пользователей и работоспособность инфраструктуры.
Решение сообщается пользователю через поддержку (тикеты) или иные каналы на сайте.</p>
<h2>5. Применимое право и правовая рамка сервиса</h2>
<p>Сервис {e(BRAND_SITE)} при определении допустимого и недопустимого использования VPN и при принятии мер
опирается на требования и общепризнанные стандарты права ЕС и США.</p>
<p>Законодательство и практика стран с авторитарным, цензурным или иным несвободным регулированием интернета —
включая Российскую Федерацию и Украину — не являются референсной рамкой настоящих правил.
Ограничения таких юрисдикций сами по себе не создают обязанности сервиса блокировать или ограничивать VPN,
если использование не противоречит применимому праву ЕС/США и настоящему документу.</p>
<p>Это не означает безусловной безнаказанности пользователя в любой стране:
вы самостоятельно оцениваете риски в месте пребывания. Сервис не консультирует по законам конкретных стран
и не гарантирует, что использование VPN будет законным или безопасным во всех юрисдикциях.</p>
<h2>6. Законное использование и запрещённые действия</h2>
<p>VPN предназначен для законного личного использования. Сервис не мониторит содержимое трафика в реальном времени
и не отвечает за ваши действия, но может ограничить или прекратить доступ при нарушении правил.</p>
<p>Запрещено использовать VPN для деятельности, противоречащей праву ЕС и/или США, включая:</p>
<ul>
<li>несанкционированный доступ к компьютерам, сетям, аккаунтам и данным;</li>
<li>распространение вредоносного ПО, взлом, DDoS, сканирование и эксплуатация уязвимостей без согласия владельца;</li>
<li>спам, фишинг, мошенничество, кража персональных данных, финансовые и иные преступления;</li>
<li>распространение CSAM и любая эксплуатация несовершеннолетних;</li>
<li>поддержка терроризма, отмывание денег, обход санкций OFAC (США), ЕС и иных обязательных ограничений;</li>
<li>массовое нарушение авторских и смежных прав (включая систематическое пиратство в коммерческих целях);</li>
<li>перепродажа, публичное распространение или коммерческое предоставление VPN-доступа третьим лицам без согласия сервиса;</li>
<li>иные действия, указанные в правилах сайта и признаваемые незаконными по праву ЕС или США.</li>
</ul>
<h2>7. Санкции, экспортный контроль и ограниченные лица</h2>
<p>Услуги не предоставляются лицам и организациям, подпадающим под обязательные санкции США (включая списки OFAC),
ЕС или иных юрисдикций, чьи ограничения сервис обязан соблюдать по закону.
Сервис может прекратить доступ при выявлении нарушений санкционных или экспортных ограничений.</p>
<h2>8. Учётные данные и передача доступа</h2>
<p>Конфигурации и ключи предназначены для личного использования владельцем аккаунта, если тариф прямо не предусматривает иное.
Передача доступа третьим лицам в коммерческих целях, массовая перепродажа или публичное распространение без согласия сервиса не допускаются.</p>
<h2>9. Лимит устройств на подписку</h2>
<p>Одна подписка допускает использование не более чем на 10 устройствах одновременно,
если на сайте для тарифа не указано иное. Для большего числа устройств нужна дополнительная подписка.
Превышение лимита нарушает правила; сервис может применить меры вплоть до приостановки аккаунта.</p>
<h2>10. Стабильность и технические ограничения</h2>
<p>VPN зависит от сетей провайдеров, ЦОД и сторонних сервисов. Возможны краткие сбои, смена IP и обслуживание.
Сервис стремится обеспечить доступность, но не гарантирует бесперебойность.</p>
<h2>11. Запросы государственных органов</h2>
<p>Сервис не ведёт целевой мониторинг содержимого пользовательского трафика.
При получении действительных и обязательных запросов от компетентных органов в рамках процедур,
предусмотренных правом ЕС, США или иной юрисдикции, обязательной для сервиса,
мы можем предоставить имеющиеся у нас данные (например, сведения об аккаунте и платежах) в объёме, требуемом законом.
Подробнее — в <a href="/privacy">политике персональных данных</a>.</p>
<p>Запросы властей РФ, Украины и иных авторитарных юрисдикций, не имеющие обязательной силы по праву ЕС/США
и не подкреплённые международно признанной процедурой, не являются основанием для произвольного раскрытия данных
или блокировки пользователя, если иное прямо не требуется применимым правом.</p>
<h2>12. Изменение документа</h2>
<p>Актуальная версия публикуется на этой странице. Продолжение использования VPN после обновления правил
означает принятие в объёме, не противоречащем заключённому договору и применимому праву.</p>
"""
return _page("Правила использования VPN", body)
def html_refund() -> str:
e = html.escape
body = f"""
<p>Порядок возврата средств за цифровые VPN-услуги {e(BRAND_SITE)}.
Документ учитывает обязательные нормы ЕС (Директива 2011/83/EU, Регламент (EU) 2019/770)
и США (права потребителей штатов, FTC). Референсная рамка — право ЕС/США.</p>
<h2>1. Цифровой характер услуги</h2>
<p>Услуга — цифровой контент не на материальном носителе, предоставляемый удалённо и, как правило,
мгновенно после оплаты (см. <a href="/delivery">доставку</a>).</p>
<h2>2. Право на отказ — потребители ЕС (14 дней)</h2>
<p>Если вы потребитель в ЕС, у вас может быть право отказаться от дистанционного договора
в течение 14 календарных дней без указания причин (Директива 2011/83/EU).</p>
<p>Исключение для цифрового контента: если при покупке вы явно запросили немедленное начало оказания услуги
и получили доступ (конфигурация в кабинете), право отказа может быть утрачено в объёме уже оказанной услуги
(ст. 16(m) Директивы). Подробнее — в <a href="/offer">оферте</a>, раздел 7.</p>
<p>До начала использования (доступ не активирован/не подключён) возврат может быть рассмотрен
по обращению в поддержку в разумный срок.</p>
<h2>3. Потребители США</h2>
<p>Для жителей США возврат за цифровые услуги с мгновенной доставкой, как правило, не предоставляется
после фактического использования, если иное не требуется императивным правом штата или настоящей политикой
(ошибочное списание, двойной платёж).</p>
<h2>4. Возврат до использования</h2>
<p>Запрос на возврат неиспользованного доступа может быть рассмотрен через
<a href="/support">поддержку</a> или <a href="/contacts">контакты</a>.
Возврат не гарантируется автоматически и зависит от технической возможности платёжного провайдера.</p>
<p>Возврат может быть отказан при нарушении <a href="/rules">правил сайта</a>,
<a href="/vpn-usage">правил VPN</a> или при причинении существенного вреда сервису.</p>
<h2>5. Ошибочный платёж и chargeback</h2>
<p>При ошибочном переводе или двойном списании обратитесь в поддержку с номером транзакции, суммой и способом оплаты.
При подтверждении ошибки средства возвращаются по процедурам платёжной системы.</p>
<p>Не злоупотребляйте chargeback без оснований — это может привести к блокировке аккаунта.</p>
<h2>6. Отказ в возврате</h2>
<p>Возврат, как правило, не выдаётся, если доступ уже использован (подключение по выданным данным),
а также при серьёзных нарушениях правил или права ЕС/США.</p>
<h2>7. Государственные и сетевые блокировки</h2>
<p>Ограничения VPN, введённые государствами, провайдерами или операторами связи,
являются обстоятельствами вне контроля сервиса и сами по себе не являются основанием для возврата,
если услуга была технически оказана с нашей стороны. Вы самостоятельно оцениваете риски
в месте пребывания (см. <a href="/vpn-usage">правила VPN</a>).</p>
<h2>8. Недоступность по вине сервиса</h2>
<p>Возврат за период подписки может быть рассмотрен, если простой серверов по вине сервиса
превысил 3 подряд календарных дня, доступ не был восстановлен заменой сервера,
и вы своевременно уведомили поддержку. Краткие сбои и плановое обслуживание не являются основанием.</p>
<p>Для потребителей ЕС сохраняются права при несоответствии цифрового контента по Регламенту (EU) 2019/770 —
в применимом объёме.</p>
<h2>9. Сроки и способ возврата</h2>
<p>Рассмотрение запроса — до 14 рабочих дней (для запросов GDPR — до 30 дней).
При одобрении — возврат тем же способом оплаты; срок зачисления — по правилам платёжной системы
(обычно 10–30 рабочих дней).</p>
<h2>10. Споры (ЕС / США)</h2>
<p>Споры — через поддержку. Потребители ЕС: платформа ODR.
Обязательные нормы страны проживания сохраняются (см. <a href="/offer">оферту</a>, раздел 16).</p>
"""
return _page("Возврат и отмена платежа", body)
def html_delivery() -> str:
e = html.escape
body = f"""
<p>Сервис {e(BRAND_SITE)} предоставляет цифровой контент (VPN-доступ).
Документ подготовлен с учётом требований ЕС (Директива 2011/83/EU, Регламент (EU) 2019/770)
и США (прозрачность для потребителей, FTC).</p>
<h2>1. Способ «доставки»</h2>
<p>Доставка осуществляется исключительно в электронной форме. После успешной оплаты доступ к конфигурациям
предоставляется автоматически: ссылки подписки и данные подключения отображаются в кабинете сразу после зачисления платежа.
Физическая доставка не применяется.</p>
<h2>2. Стоимость доставки</h2>
<p>Отдельная плата за доставку не взимается. Цена на странице тарифа включает предоставление цифрового доступа
(принцип прозрачности цены — право потребителей ЕС/США).</p>
<h2>3. Сроки</h2>
<p>Доступ предоставляется сразу после подтверждения оплаты платёжной системой (обычно в течение нескольких минут).
При задержке у провайдера — в разумный срок, как правило до 24 часов; обратитесь в
<a href="/support">поддержку</a>.</p>
<p>Для потребителей ЕС: при оформлении заказа вы запрашиваете немедленное начало оказания услуги;
это может повлиять на 14-дневное право отказа — см. <a href="/offer">оферту</a> и
<a href="/refund">политику возврата</a>.</p>
<h2>4. Регионы</h2>
<p>Сервис доступен пользователям в регионах, где доступны сайт и выбранные способы оплаты,
с учётом ограничений санкций OFAC/ЕС (см. <a href="/offer">оферту</a>).</p>
<h2>5. Подтверждение получения</h2>
<p>Предоставление доступа фиксируется автоматически в кабинете. Это считается исполнением обязанности
по поставке цифрового контента. При проблемах после оплаты — откройте тикет в поддержке.</p>
<h2>6. Информация до покупки (ЕС / США)</h2>
<p>До оплаты на сайте отображаются: наименование услуги, цена, срок подписки, основные характеристики тарифа
(лимиты, протоколы). Полные условия — в <a href="/offer">оферте</a>,
<a href="/privacy">политике персональных данных</a> и <a href="/contacts">контактах</a>.</p>
"""
return _page("Доставка", body)
def html_contacts() -> str:
e = html.escape
body = f"""
<p>Актуальные контактные данные сервиса {e(BRAND_SITE)}.
По вопросам персональных данных (GDPR, CCPA/CPRA) и прав потребителей ЕС/США используйте каналы ниже.</p>
<div class="card">
<p><strong>Оператор сервиса (контролёр данных)</strong><br/>{e(OPERATOR_NAME)}</p>
<p><strong>Email</strong><br/>
Общие вопросы: <a href="mailto:{e(SUPPORT_EMAIL)}">{e(SUPPORT_EMAIL)}</a><br/>
Приватность и GDPR/CCPA: <a href="mailto:{e(SUPPORT_EMAIL)}">{e(SUPPORT_EMAIL)}</a>
(тема: «Privacy request»)</p>
<p><strong>Поддержка</strong><br/>
<a href="mailto:{e(SUPPORT_EMAIL)}">{e(SUPPORT_EMAIL)}</a><br/>
Обращения через раздел <a href="/support">Поддержка</a> (тикеты) в кабинете.
Зарегистрированные пользователи могут создать тикет и получить ответ на email.</p>
<p class="muted">Срок ответа на запросы по персональным данным — до 30 дней (GDPR)
или в сроки CCPA/CPRA (обычно до 45 дней).</p>
</div>
<h2>Права потребителей и споры (ЕС / США)</h2>
<p>Жители ЕС/ЕЭЗ могут обратиться в надзорный орган по защите данных своей страны (список — EDPB)
и использовать платформу Online Dispute Resolution (ODR) Еврокомиссии.</p>
<p>Жители США (включая Калифорнию) — по вопросам персональных данных см.
<a href="/privacy">политику</a>; жалобы можно подавать в компетентный орган штата
(например, California Attorney General — Privacy).</p>
"""
return _page("Контакты", body)
def html_support() -> str:
e = html.escape
body = f"""
<p>Поддержка сервиса {e(BRAND_SITE)}.</p>
<div class="card">
<ol>
<li>Войдите в <a href="/">кабинет</a>.</li>
<li>Откройте раздел «Поддержка» и создайте тикет.</li>
<li>Укажите ID конфигурации/подписки, что уже пробовали, и приложите скриншоты при необходимости.</li>
</ol>
<p>Email: <a href="mailto:{e(SUPPORT_EMAIL)}">{e(SUPPORT_EMAIL)}</a></p>
</div>
<p>Перед обращением проверьте: актуальность приложения, корректность скопированной ссылки подписки,
статус серверов на странице <a href="/status">Статус серверов</a>.</p>
<p>Справка по типичным вопросам: <a href="/help">Помощь</a>.</p>
"""
return _page("Поддержка", body)
def html_help() -> str:
e = html.escape
body = f"""
<p>Краткая справка по кабинету {e(BRAND)}: регистрация, тарифы, подписка Remnawave, устройства и лимиты.</p>
<h2>С чего начать</h2>
<ol>
<li>Зарегистрируйтесь или войдите в кабинет (логин/пароль, Telegram или Passkey).</li>
<li>Пополните баланс доступным способом оплаты.</li>
<li>Выберите тариф и оплатите с баланса.</li>
<li>Скопируйте ссылку подписки из кабинета и импортируйте в клиент (Hiddify, v2rayNG, Streisand и др.).</li>
</ol>
<h2>Регистрация и вход</h2>
<p>При регистрации укажите логин и пароль. Забыли пароль — сброс со страницы входа (если настроен SMTP).
Реферальная ссылка при регистрации (если есть) применяется автоматически.</p>
<h2>Баланс и оплата</h2>
<p>В кабинете откройте «Баланс». Способы оплаты зависят от настроек сервиса.
Мы не списываем карту автоматически без вашего действия: услуги оплачиваются с баланса сайта.
В профиле можно включить добровольное автопродление с баланса — это не привязка карты,
а списание уже зачисленных средств при истечении тарифа.</p>
<h2>Подписка Remnawave</h2>
<p>После покупки в кабинете появляется ссылка подписки <code>https://…</code> для мультисерверного доступа.
У подписки есть лимит трафика и устройств (по тарифу и настройкам панели).
При необходимости докупите трафик или слоты устройств в соответствующих разделах кабинета.</p>
<h2>Устройства и правила</h2>
<p>Не превышайте лимит устройств — иначе доступ может быть ограничен до покупки слотов или удаления лишних устройств.
Одна выданная подписка/ссылка не предназначена для массовой раздачи.
Злоупотребления могут привести к блокировке аккаунта. Возвраты — см.
<a href="/refund">политику возврата</a> и <a href="/rules">правила</a>.</p>
<h2>Поддержка</h2>
<p>Создайте тикет в разделе <a href="/support">Поддержка</a>.
Укажите тип подключения и что уже пробовали. Перед обращением обновите приложение и проверьте ссылку.</p>
"""
return _page("Помощь", body)
def html_status() -> str:
e = html.escape
status = e(STATUS_URL) if STATUS_URL else "#"
body = f"""
<p>Актуальный статус узлов и инфраструктуры сервиса {e(BRAND_SITE)}.</p>
<div class="card">
<p>Публичная страница статуса:</p>
<p><a href="{status}" target="_blank" rel="noopener">{status}</a></p>
<p class="muted">Краткие сбои и плановое обслуживание обычно отражаются на статус-странице.
При длительной недоступности (&gt;3 суток подряд по вине сервиса без замены сервера)
см. <a href="/refund">условия возврата</a>.</p>
</div>
<p>Если сервис недоступен — напишите в <a href="/support">поддержку</a>.</p>
"""
return _page("Статус серверов", body)
def html_cookies() -> str:
e = html.escape
script = """
<script>
(function () {
var KEY = "cookie_consent";
function get() {
try { return localStorage.getItem(KEY) || ""; } catch (e) { return ""; }
}
function set(v) {
try { localStorage.setItem(KEY, v); } catch (e) {}
var el = document.getElementById("cookie-status");
if (el) {
el.textContent = v === "all"
? "Выбрано: принять все (включая опциональные)."
: v === "necessary"
? "Выбрано: только необходимые."
: "Выбор ещё не сохранён.";
}
}
document.addEventListener("DOMContentLoaded", function () {
set(get());
var all = document.getElementById("cookie-accept-all");
var nec = document.getElementById("cookie-necessary");
if (all) all.addEventListener("click", function () { set("all"); });
if (nec) nec.addEventListener("click", function () { set("necessary"); });
});
})();
</script>
"""
body = f"""
<p>Настройки cookie для сайта {e(BRAND_SITE)}. Подробности — в разделе 5
<a href="/privacy">Политики обработки персональных данных</a>.</p>
<div class="card">
<p><strong>Строго необходимые</strong> — сессия, безопасность, работа кабинета. Согласие не требуется.</p>
<p><strong>Функциональные</strong> — запоминание вашего выбора cookie.</p>
<p><strong>Опциональные</strong> — сторонние виджеты (например, чат поддержки), если включены на сайте.
Загружаются только при выборе «Принять все».</p>
<div class="btn-row">
<button type="button" class="cookie-btn primary" id="cookie-accept-all">Принять все</button>
<button type="button" class="cookie-btn" id="cookie-necessary">Только необходимые</button>
</div>
<p class="muted" id="cookie-status">Выбор ещё не сохранён.</p>
</div>
<p class="muted">Выбор хранится в localStorage браузера (<code>cookie_consent</code>) до 12 месяцев
или до очистки данных сайта.</p>
"""
return _page("Настройки cookie", body, extra_head=script)
+364
View File
@@ -0,0 +1,364 @@
"""SMTP-рассылка важных уведомлений пользователям."""
from __future__ import annotations
import asyncio
import html as html_lib
import logging
import re
import smtplib
from email.message import EmailMessage
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from config import Settings
from services.db import Database
logger = logging.getLogger(__name__)
_TAG_RE = re.compile(r"<[^>]+>")
_WS_RE = re.compile(r"[ \t]+\n")
# Фирменные цвета VPN Service (email-safe, без «фиолетового AI»)
_ACCENT = "#0d9f6e"
_ACCENT_DARK = "#0a7a54"
_BG = "#eef3f0"
_CARD = "#ffffff"
_TEXT = "#12201a"
_MUTED = "#5c7167"
_LINE = "#d5e0da"
_WARN_BG = "#fff8e8"
_WARN_BORDER = "#e8c96a"
_WARN_TEXT = "#6b5420"
def html_to_plain(html: str) -> str:
text = (html or "").replace("<br>", "\n").replace("<br/>", "\n").replace("<br />", "\n")
text = text.replace("</p>", "\n").replace("</div>", "\n").replace("</li>", "\n")
text = _TAG_RE.sub("", text)
text = (
text.replace("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", '"')
)
text = _WS_RE.sub("\n", text)
return text.strip()
def _e(value: str) -> str:
return html_lib.escape(value or "", quote=True)
def smtp_configured(settings: Settings) -> bool:
return bool((settings.smtp_host or "").strip() and (settings.smtp_from or "").strip())
def render_email_layout(
*,
title: str,
body_html: str,
preheader: str = "",
footer_note: str = "",
webapp_url: str = "",
cta_url: str = "",
cta_label: str = "",
) -> str:
"""Брендированная HTML-обёртка для писем (таблицы + inline CSS)."""
title_e = _e(title)
pre_e = _e(preheader or title)
footer_e = _e(
footer_note
or "Это письмо отправлено автоматически. Отвечать на него не нужно."
)
brand_url = (webapp_url or "").rstrip("/")
brand_link = (
f'<a href="{_e(brand_url)}" style="color:{_ACCENT};text-decoration:none;font-weight:600;">'
f"Открыть кабинет</a>"
if brand_url
else ""
)
cta_block = ""
if cta_url and cta_label:
cta_block = f"""
<tr>
<td style="padding:8px 28px 20px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="border-radius:10px;background:{_ACCENT};">
<a href="{_e(cta_url)}"
style="display:inline-block;padding:14px 28px;font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;
font-size:15px;font-weight:700;color:#ffffff;text-decoration:none;border-radius:10px;">
{_e(cta_label)}
</a>
</td>
</tr>
</table>
</td>
</tr>"""
return f"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light" />
<meta name="supported-color-schemes" content="light" />
<title>{title_e}</title>
<!--[if mso]><style>table,td{{font-family:Arial,sans-serif!important}}</style><![endif]-->
</head>
<body style="margin:0;padding:0;background:{_BG};">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">
{pre_e}
</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="background:{_BG};padding:24px 12px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="max-width:560px;background:{_CARD};border-radius:16px;overflow:hidden;
border:1px solid {_LINE};box-shadow:0 8px 28px rgba(18,32,26,0.08);">
<tr>
<td style="background:linear-gradient(135deg,{_ACCENT_DARK} 0%,{_ACCENT} 100%);
background-color:{_ACCENT};padding:22px 28px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;
font-size:22px;font-weight:800;color:#ffffff;letter-spacing:-0.02em;">
🦊 VPN Service
</td>
</tr>
<tr>
<td style="padding-top:4px;font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;
font-size:13px;color:rgba(255,255,255,0.85);">
Безопасный доступ · личный кабинет
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:28px 28px 8px;font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;
color:{_TEXT};">
<h1 style="margin:0 0 16px;font-size:20px;line-height:1.3;font-weight:700;color:{_TEXT};">
{title_e}
</h1>
<div style="font-size:15px;line-height:1.55;color:{_TEXT};">
{body_html}
</div>
</td>
</tr>
{cta_block}
<tr>
<td style="padding:8px 28px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="border-top:1px solid {_LINE};">
<tr>
<td style="padding-top:16px;font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;
font-size:12px;line-height:1.5;color:{_MUTED};">
{footer_e}
{"<br/>" + brand_link if brand_link else ""}
</td>
</tr>
</table>
</td>
</tr>
</table>
<p style="margin:16px 0 0;font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;
font-size:11px;color:{_MUTED};">
© VPN Service
</p>
</td>
</tr>
</table>
</body>
</html>"""
def render_info_box(html_inner: str, *, kind: str = "info") -> str:
if kind == "warn":
bg, border, color = _WARN_BG, _WARN_BORDER, _WARN_TEXT
else:
bg, border, color = "#f0faf5", _LINE, _MUTED
return f"""
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="margin:16px 0;background:{bg};border:1px solid {border};border-radius:12px;">
<tr>
<td style="padding:14px 16px;font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;
font-size:13px;line-height:1.5;color:{color};">
{html_inner}
</td>
</tr>
</table>"""
def render_password_reset_email(*, reset_url: str, webapp_url: str = "") -> str:
body = f"""
<p style="margin:0 0 12px;">Мы получили запрос на сброс пароля для вашего аккаунта VPN Service.</p>
<p style="margin:0 0 4px;">Нажмите кнопку ниже, чтобы задать новый пароль. Ссылка действует <strong>1 час</strong>.</p>
{render_info_box(
"<strong>Безопасность.</strong> Если вы не запрашивали сброс — просто проигнорируйте это письмо. "
"Пароль не изменится, пока вы сами не подтвердите действие по ссылке.",
kind="warn",
)}
<p style="margin:0;font-size:12px;color:{_MUTED};">
Кнопка не работает? Скопируйте ссылку в браузер:<br/>
<a href="{_e(reset_url)}" style="color:{_ACCENT};word-break:break-all;">{_e(reset_url)}</a>
</p>
"""
return render_email_layout(
title="Сброс пароля",
preheader="Ссылка для сброса пароля действует 1 час",
body_html=body,
cta_url=reset_url,
cta_label="Сбросить пароль",
webapp_url=webapp_url,
footer_note="Письмо отправлено, потому что для аккаунта указан этот email.",
)
def render_smtp_test_email(*, webapp_url: str = "") -> str:
body = f"""
<p style="margin:0 0 12px;">Поздравляем — SMTP настроен правильно.</p>
<p style="margin:0 0 12px;">Это тестовое письмо из админ-панели VPN Service. Теперь можно отправлять
сброс пароля и важные уведомления пользователям.</p>
{render_info_box(
"<strong>Что дальше.</strong> Убедитесь, что у пользователей сохранён email в кабинете, "
"и проверьте папку «Спам», если письмо не видно во входящих.",
kind="info",
)}
"""
return render_email_layout(
title="SMTP работает",
preheader="Тестовое письмо — почта настроена верно",
body_html=body,
webapp_url=webapp_url,
cta_url=(webapp_url or "").rstrip("/") + "/admin" if webapp_url else "",
cta_label="Открыть админку" if webapp_url else "",
footer_note="Тест из раздела Админка → SMTP.",
)
def render_notification_email(
*,
title: str,
body_html: str,
webapp_url: str = "",
preheader: str = "",
) -> str:
"""Обернуть произвольное HTML-уведомление в фирменный шаблон."""
return render_email_layout(
title=title,
preheader=preheader or title,
body_html=f'<div style="font-size:15px;line-height:1.55;">{body_html}</div>',
webapp_url=webapp_url,
footer_note="Уведомление аккаунта VPN Service. Отключить email можно в кабинете → Аккаунт.",
)
def send_email_sync(
settings: Settings,
*,
to: str,
subject: str,
html: str,
plain: str | None = None,
) -> None:
host = (settings.smtp_host or "").strip()
from_addr = (settings.smtp_from or "").strip()
to_addr = (to or "").strip()
if not host or not from_addr or not to_addr:
raise ValueError("SMTP не настроен или нет получателя")
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = from_addr
msg["To"] = to_addr
body_plain = plain if plain is not None else html_to_plain(html)
msg.set_content(body_plain or subject)
if html.strip():
msg.add_alternative(html, subtype="html")
port = int(settings.smtp_port or 587)
user = (settings.smtp_user or "").strip()
password = settings.smtp_password or ""
use_ssl = bool(settings.smtp_ssl)
use_tls = bool(settings.smtp_tls) and not use_ssl
if use_ssl:
with smtplib.SMTP_SSL(host, port, timeout=30) as smtp:
if user:
smtp.login(user, password)
smtp.send_message(msg)
return
with smtplib.SMTP(host, port, timeout=30) as smtp:
smtp.ehlo()
if use_tls:
smtp.starttls()
smtp.ehlo()
if user:
smtp.login(user, password)
smtp.send_message(msg)
async def send_email(
settings: Settings,
*,
to: str,
subject: str,
html: str,
plain: str | None = None,
) -> None:
await asyncio.to_thread(
send_email_sync,
settings,
to=to,
subject=subject,
html=html,
plain=plain,
)
async def notify_important(
*,
db: Database,
settings: Settings,
telegram_id: int,
html: str,
subject: str,
bot=None,
) -> None:
"""Telegram (если есть) + email (если SMTP и email пользователя заданы)."""
user = db.get_user(telegram_id)
if user and user.banned:
return
want_tg = True if not user else bool(user.notify_telegram)
want_email = True if not user else bool(user.notify_email)
if want_tg and bot is not None and telegram_id > 0:
try:
await bot.send_message(telegram_id, html, parse_mode="HTML")
except Exception: # noqa: BLE001
logger.warning("telegram notify failed tg=%s", telegram_id)
if not want_email or not smtp_configured(settings):
return
email = (user.email if user else None) or ""
if not email.strip():
return
try:
# Telegram получает компактный HTML; в письме — фирменный шаблон
title = (subject or "VPN Service").replace("— VPN Service", "").strip() or "Уведомление"
mail_html = render_notification_email(
title=title,
body_html=html,
webapp_url=settings.webapp_url or "",
preheader=html_to_plain(html)[:120],
)
await send_email(settings, to=email.strip(), subject=subject, html=mail_html)
except Exception: # noqa: BLE001
logger.warning("email notify failed tg=%s to=%s", telegram_id, email, exc_info=True)
+126
View File
@@ -0,0 +1,126 @@
"""Статус и TCP-пинг нод Remnawave."""
from __future__ import annotations
import asyncio
import socket
from dataclasses import dataclass
from remnawave import RemnawaveSDK
@dataclass(frozen=True, slots=True)
class NodeStatus:
name: str
address: str
port: int | None
country_code: str
panel_connected: bool
panel_disabled: bool
tcp_ok: bool
tcp_ms: float | None
users_online: int | None
last_status_message: str | None
@property
def online(self) -> bool:
if self.panel_disabled:
return False
# Онлайн только если панель видит ноду и TCP отвечает
return self.panel_connected and self.tcp_ok
@property
def label(self) -> str:
if self.panel_disabled:
return "Выключен"
if self.online:
return "Онлайн"
return "Оффлайн"
async def tcp_ping(host: str, port: int, timeout: float = 2.5) -> tuple[bool, float | None]:
loop = asyncio.get_running_loop()
started = loop.time()
def _connect() -> None:
with socket.create_connection((host, port), timeout=timeout):
return None
try:
await asyncio.wait_for(asyncio.to_thread(_connect), timeout=timeout + 0.5)
ms = (loop.time() - started) * 1000
return True, round(ms, 1)
except Exception: # noqa: BLE001 — любой сбой = недоступен
return False, None
async def fetch_nodes_status(sdk: RemnawaveSDK) -> list[NodeStatus]:
nodes = await sdk.nodes.get_all_nodes()
results: list[NodeStatus] = []
async def _one(node) -> NodeStatus:
port = node.port or 443
tcp_ok, tcp_ms = await tcp_ping(node.address, port)
return NodeStatus(
name=node.name,
address=node.address,
port=node.port,
country_code=node.country_code or "XX",
panel_connected=bool(node.is_connected),
panel_disabled=bool(node.is_disabled),
tcp_ok=tcp_ok,
tcp_ms=tcp_ms,
users_online=node.users_online,
last_status_message=node.last_status_message,
)
tasks = [_one(node) for node in nodes]
if tasks:
results = list(await asyncio.gather(*tasks))
# сохраняем порядок панели
return results
def format_nodes_status(nodes: list[NodeStatus], *, detailed: bool = False) -> str:
if not nodes:
return "Серверы не найдены в панели Remnawave."
online = sum(1 for n in nodes if n.online)
lines = [
f"<b>Статус серверов</b>",
f"Онлайн: <b>{online}</b> / {len(nodes)}",
"",
]
for n in nodes:
flag = _flag(n.country_code)
if n.online:
icon = "🟢"
ping = f" · {n.tcp_ms:.0f} ms" if n.tcp_ms is not None else ""
extra = f" · 👥 {n.users_online}" if n.users_online is not None else ""
lines.append(f"{icon} {flag} <b>{n.name}</b> — Онлайн{ping}{extra}")
elif n.panel_disabled:
icon = ""
lines.append(f"{icon} {flag} <b>{n.name}</b> — Выключен")
else:
icon = "🔴"
lines.append(f"{icon} {flag} <b>{n.name}</b> — Оффлайн")
if detailed:
reasons: list[str] = []
if not n.panel_connected:
reasons.append("панель не видит ноду")
if not n.tcp_ok:
reasons.append(f"нет ответа {n.address}:{n.port or 443}")
if n.last_status_message:
reasons.append(n.last_status_message)
if reasons:
lines.append("" + "; ".join(reasons))
return "\n".join(lines)
def _flag(code: str) -> str:
code = (code or "XX").upper()
if len(code) != 2 or not code.isalpha():
return "🌐"
return "".join(chr(0x1F1E6 + ord(c) - ord("A")) for c in code)
+273
View File
@@ -0,0 +1,273 @@
"""OpenRouter API: модели и chat completions."""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Any
import httpx
logger = logging.getLogger(__name__)
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
DEFAULT_MODELS = (
"openai/gpt-4o-mini",
"google/gemini-2.5-flash",
"anthropic/claude-3.5-sonnet",
"deepseek/deepseek-chat",
"meta-llama/llama-3.3-70b-instruct",
)
DEFAULT_SYSTEM_PROMPT = """Ты — ИИ-поддержка VPN-сервиса (Remnawave / VPN Service).
Отвечай кратко, по делу, на русском. Помогай с подключением, клиентами, тарифами, балансом, трафиком и устройствами.
Правила:
- Опирайся на FAQ и переписку тикета.
- Не выдумывай факты о балансе, платежах и сроках подписки пользователя если нужны точные данные аккаунта, эскалируй.
- Не обещай возврат денег без эскалации.
- Не раскрывай внутренние API, ключи, админку.
- Если вопрос сложный, спорный, про оплату/возврат/бан или ты не уверен эскалируй человеку.
Формат ответа ТОЛЬКО JSON без markdown:
{"action":"answer","reply":"текст ответа пользователю"}
или
{"action":"escalate","reason":"кратко почему нужен человек"}
"""
@dataclass(slots=True)
class OpenRouterConfig:
enabled: bool = False
api_key: str = ""
model: str = DEFAULT_MODELS[0]
models: list[str] = field(default_factory=lambda: list(DEFAULT_MODELS[:3]))
system_prompt: str = DEFAULT_SYSTEM_PROMPT
auto_reply: bool = True
@property
def ready(self) -> bool:
return bool(self.enabled and self.api_key.strip() and self.model.strip())
def models_for_request(self) -> list[str]:
"""Primary + fallbacks (без дублей)."""
ordered: list[str] = []
for mid in [self.model, *self.models]:
mid = (mid or "").strip()
if mid and mid not in ordered:
ordered.append(mid)
return ordered
@dataclass(slots=True)
class AiDecision:
action: str # answer | escalate
reply: str = ""
reason: str = ""
model: str = ""
raw: str = ""
def _truthy(value: str) -> bool:
return (value or "").strip().lower() in ("1", "true", "yes", "on", "да")
def load_openrouter_config(db) -> OpenRouterConfig:
raw = db.get_settings_map(
[
"openrouter_enabled",
"openrouter_api_key",
"openrouter_model",
"openrouter_models",
"openrouter_system_prompt",
"openrouter_auto_reply",
]
)
models: list[str] = []
models_raw = (raw.get("openrouter_models") or "").strip()
if models_raw:
try:
parsed = json.loads(models_raw)
if isinstance(parsed, list):
models = [str(x).strip() for x in parsed if str(x).strip()]
except json.JSONDecodeError:
models = [x.strip() for x in models_raw.split(",") if x.strip()]
if not models:
models = list(DEFAULT_MODELS[:3])
model = (raw.get("openrouter_model") or "").strip() or (models[0] if models else DEFAULT_MODELS[0])
prompt = (raw.get("openrouter_system_prompt") or "").strip() or DEFAULT_SYSTEM_PROMPT
auto_raw = raw.get("openrouter_auto_reply")
auto_reply = True if auto_raw == "" else _truthy(auto_raw)
return OpenRouterConfig(
enabled=_truthy(raw.get("openrouter_enabled") or ""),
api_key=(raw.get("openrouter_api_key") or "").strip(),
model=model,
models=models,
system_prompt=prompt,
auto_reply=auto_reply,
)
def mask_api_key(key: str) -> str:
key = (key or "").strip()
if not key:
return ""
if len(key) <= 8:
return "••••••••"
return f"{key[:6]}{key[-4:]}"
def config_public_dict(cfg: OpenRouterConfig) -> dict[str, Any]:
return {
"enabled": cfg.enabled,
"api_key_set": bool(cfg.api_key),
"api_key_masked": mask_api_key(cfg.api_key),
"model": cfg.model,
"models": cfg.models,
"system_prompt": cfg.system_prompt,
"auto_reply": cfg.auto_reply,
"ready": cfg.ready,
"defaults": list(DEFAULT_MODELS),
}
def _headers(api_key: str, *, referer: str = "", title: str = "VPN Bot Support") -> dict[str, str]:
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
if referer:
headers["HTTP-Referer"] = referer
headers["X-Title"] = title
return headers
async def list_models(api_key: str, *, referer: str = "") -> list[dict[str, Any]]:
async with httpx.AsyncClient(timeout=45.0) as client:
resp = await client.get(
f"{OPENROUTER_BASE}/models",
headers=_headers(api_key, referer=referer),
)
resp.raise_for_status()
data = resp.json()
items = data.get("data") if isinstance(data, dict) else data
out: list[dict[str, Any]] = []
for item in items or []:
mid = str(item.get("id") or "").strip()
if not mid:
continue
out.append(
{
"id": mid,
"name": str(item.get("name") or mid),
"context_length": item.get("context_length"),
"pricing": item.get("pricing") or {},
}
)
out.sort(key=lambda x: x["id"].lower())
return out
async def chat_completion(
*,
api_key: str,
model: str,
messages: list[dict[str, str]],
models: list[str] | None = None,
referer: str = "",
temperature: float = 0.3,
max_tokens: int = 900,
) -> tuple[str, str]:
"""Возвращает (text, used_model)."""
payload: dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
fallbacks = [m for m in (models or []) if m and m != model]
if fallbacks:
payload["models"] = [model, *fallbacks]
payload["route"] = "fallback"
async with httpx.AsyncClient(timeout=90.0) as client:
resp = await client.post(
f"{OPENROUTER_BASE}/chat/completions",
headers=_headers(api_key, referer=referer),
json=payload,
)
if resp.status_code >= 400:
detail = resp.text[:500]
raise RuntimeError(f"OpenRouter HTTP {resp.status_code}: {detail}")
data = resp.json()
used = str(data.get("model") or model)
choices = data.get("choices") or []
if not choices:
raise RuntimeError("OpenRouter: пустой ответ")
message = choices[0].get("message") or {}
content = message.get("content")
if isinstance(content, list):
parts = []
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
parts.append(str(part.get("text") or ""))
elif isinstance(part, str):
parts.append(part)
text = "\n".join(parts).strip()
else:
text = str(content or "").strip()
if not text:
raise RuntimeError("OpenRouter: пустой content")
return text, used
_JSON_RE = re.compile(r"\{[\s\S]*\}")
def parse_ai_decision(raw: str, *, used_model: str = "") -> AiDecision:
text = (raw or "").strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text).strip()
payload: dict[str, Any] | None = None
try:
payload = json.loads(text)
except json.JSONDecodeError:
match = _JSON_RE.search(text)
if match:
try:
payload = json.loads(match.group(0))
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict):
action = str(payload.get("action") or "").strip().lower()
reply = str(payload.get("reply") or payload.get("message") or "").strip()
reason = str(payload.get("reason") or "").strip()
if action == "escalate":
return AiDecision(
action="escalate",
reason=reason or reply or "Нужен человек",
model=used_model,
raw=raw,
)
if action == "answer" and reply:
return AiDecision(action="answer", reply=reply, model=used_model, raw=raw)
if reply and not action:
return AiDecision(action="answer", reply=reply, model=used_model, raw=raw)
# Fallback: если модель ответила обычным текстом — считаем ответом
if len(text) >= 8 and "{" not in text[:20]:
return AiDecision(action="answer", reply=text, model=used_model, raw=raw)
return AiDecision(
action="escalate",
reason="ИИ вернул неразборчивый ответ",
model=used_model,
raw=raw,
)
+49
View File
@@ -0,0 +1,49 @@
"""Хеширование паролей (PBKDF2-SHA256, без внешних зависимостей)."""
from __future__ import annotations
import hashlib
import hmac
import secrets
_ITERATIONS = 210_000
_SALT_BYTES = 16
_DKLEN = 32
def hash_password(password: str) -> str:
salt = secrets.token_bytes(_SALT_BYTES)
digest = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
_ITERATIONS,
dklen=_DKLEN,
)
return f"pbkdf2_sha256${_ITERATIONS}${salt.hex()}${digest.hex()}"
def verify_password(password: str, password_hash: str) -> bool:
try:
algo, iters_s, salt_hex, digest_hex = password_hash.split("$", 3)
if algo != "pbkdf2_sha256":
return False
iterations = int(iters_s)
salt = bytes.fromhex(salt_hex)
expected = bytes.fromhex(digest_hex)
except (ValueError, TypeError):
return False
got = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
iterations,
dklen=len(expected),
)
return hmac.compare_digest(got, expected)
def new_session_token() -> str:
return secrets.token_urlsafe(32)
+105
View File
@@ -0,0 +1,105 @@
"""Зачисление оплаты по инвойсу (CryptoBot / Heleket)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from remnawave import RemnawaveSDK
from services.db import Database
from services.purchase import purchase_tariff
from services.tariffs import Tariff, get_tariff
@dataclass(frozen=True, slots=True)
class FulfillResult:
ok: bool
message: str
already: bool = False
async def fulfill_paid_invoice(
*,
db: Database,
sdk: RemnawaveSDK,
tariffs: list[Tariff],
local: dict[str, Any],
) -> FulfillResult:
"""Идемпотентно зачислить оплаченный инвойс."""
invoice_id = int(local["invoice_id"])
provider = str(local.get("provider") or "cryptobot")
if local.get("status") == "paid":
return FulfillResult(ok=True, message="Уже зачислено", already=True)
if not db.mark_invoice_paid(invoice_id, provider=provider):
return FulfillResult(ok=True, message="Уже зачислено", already=True)
kind = local["kind"]
amount = float(local["amount_rub"])
telegram_id = int(local["telegram_id"])
try:
db.log_payment_event(
provider=provider,
event_type=f"fulfill:{kind}",
telegram_id=telegram_id,
amount=amount,
invoice_ref=str(invoice_id),
status="ok",
)
except Exception: # noqa: BLE001
pass
meta_prefix = f"{provider}:{invoice_id}"
if kind == "topup":
if provider == "lava":
balance_kind = "lava_topup"
via = "Lava"
elif provider == "anypay":
balance_kind = "anypay_topup"
via = "AnyPay"
else:
balance_kind = "crypto_topup"
via = f"криптой ({provider})"
balance = db.add_balance(
telegram_id,
amount,
kind=balance_kind,
meta=meta_prefix,
)
return FulfillResult(
ok=True,
message=(
f"✅ <b>Баланс пополнен</b>\n\n"
f"+{amount:.0f} ₽ через {via}\n"
f"Доступно: <b>{balance:.0f} ₽</b>"
),
)
if kind == "tariff":
tariff = get_tariff(tariffs, str(local.get("tariff_id") or ""))
if not tariff:
balance = db.add_balance(
telegram_id,
amount,
kind="crypto_topup",
meta=f"{meta_prefix}:unknown_tariff",
)
return FulfillResult(
ok=True,
message=(
"Оплата получена, тариф не найден — зачислено на баланс: "
f"<b>{balance:.0f} ₽</b>"
),
)
result = await purchase_tariff(
db=db,
sdk=sdk,
telegram_id=telegram_id,
tariff=tariff,
charge_balance=False,
payment_meta=meta_prefix,
)
return FulfillResult(ok=result.ok, message=result.message)
return FulfillResult(ok=False, message="Неизвестный тип счёта")
+233
View File
@@ -0,0 +1,233 @@
"""Сборка платёжных клиентов и публичный payload для админки."""
from __future__ import annotations
from typing import Any
from config import Settings, get_settings
from services.anypay import Anypay
from services.cryptopay import CryptoPay
from services.digiseller import Digiseller
from services.env_file import (
BOOL_ENV_KEYS,
PAYMENT_ENV_KEYS,
SECRET_ENV_KEYS,
env_file_path,
env_writable,
mask_secret,
upsert_env_keys,
)
from services.heleket import Heleket
from services.lava import Lava
def build_payment_clients(settings: Settings) -> dict[str, Any]:
return {
"cryptopay": CryptoPay(
settings.crypto_bot_token,
testnet=settings.crypto_pay_testnet,
),
"heleket": Heleket(
settings.heleket_merchant_id,
settings.heleket_api_key,
callback_url=settings.heleket_callback_url,
currency=settings.heleket_currency,
),
"digiseller": Digiseller(
settings.digiseller_seller_id,
settings.digiseller_api_key,
product_ids=settings.digiseller_product_ids,
pay_url=settings.digiseller_pay_url,
credit_from=settings.digiseller_credit_from,
),
"lava": Lava(
settings.lava_shop_id,
settings.lava_secret_key,
additional_key=settings.lava_additional_key,
hook_url=settings.lava_hook_url,
success_url=settings.lava_success_url,
fail_url=settings.lava_fail_url,
expire_minutes=settings.lava_expire_minutes,
),
"anypay": Anypay(
settings.anypay_api_id,
settings.anypay_api_key,
settings.anypay_project_id,
currency=settings.anypay_currency,
method=settings.anypay_method,
success_url=settings.anypay_success_url,
fail_url=settings.anypay_fail_url,
default_email=settings.anypay_email or "pay@anypay.local",
check_ip=settings.anypay_check_ip,
),
}
def apply_payment_runtime(app: Any, settings: Settings) -> dict[str, Any]:
clients = build_payment_clients(settings)
app["settings"] = settings
app["cryptopay"] = clients["cryptopay"]
app["heleket"] = clients["heleket"]
app["digiseller"] = clients["digiseller"]
app["lava"] = clients["lava"]
app["anypay"] = clients["anypay"]
inject = app.get("inject")
if inject is not None:
inject.settings = settings
inject.cryptopay = clients["cryptopay"]
inject.heleket = clients["heleket"]
inject.digiseller = clients["digiseller"]
inject.lava = clients["lava"]
inject.anypay = clients["anypay"]
return clients
def payments_public_dict(settings: Settings) -> dict[str, Any]:
path = env_file_path()
return {
"env_path": str(path),
"env_writable": env_writable(path),
"crypto": {
"enabled": bool(settings.crypto_bot_token),
"token_set": bool(settings.crypto_bot_token),
"token_hint": mask_secret(settings.crypto_bot_token),
"testnet": bool(settings.crypto_pay_testnet),
},
"heleket": {
"enabled": bool(settings.heleket_merchant_id and settings.heleket_api_key),
"merchant_id": settings.heleket_merchant_id,
"api_key_set": bool(settings.heleket_api_key),
"api_key_hint": mask_secret(settings.heleket_api_key),
"callback_url": settings.heleket_callback_url,
"currency": settings.heleket_currency or "RUB",
},
"lava": {
"enabled": bool(settings.lava_shop_id and settings.lava_secret_key),
"shop_id": settings.lava_shop_id,
"secret_key_set": bool(settings.lava_secret_key),
"secret_key_hint": mask_secret(settings.lava_secret_key),
"additional_key_set": bool(settings.lava_additional_key),
"additional_key_hint": mask_secret(settings.lava_additional_key),
"hook_url": settings.lava_hook_url,
"success_url": settings.lava_success_url,
"fail_url": settings.lava_fail_url,
"expire_minutes": settings.lava_expire_minutes,
},
"anypay": {
"enabled": bool(
settings.anypay_api_id
and settings.anypay_api_key
and settings.anypay_project_id
),
"api_id": settings.anypay_api_id,
"api_key_set": bool(settings.anypay_api_key),
"api_key_hint": mask_secret(settings.anypay_api_key),
"project_id": settings.anypay_project_id,
"currency": settings.anypay_currency or "RUB",
"method": settings.anypay_method,
"success_url": settings.anypay_success_url,
"fail_url": settings.anypay_fail_url,
"email": settings.anypay_email,
"check_ip": bool(settings.anypay_check_ip),
"notify_url": (
f"{settings.webapp_url.rstrip('/')}/api/payments/anypay"
if settings.webapp_url
else "/api/payments/anypay"
),
},
"digiseller": {
"enabled": bool(settings.digiseller_seller_id and settings.digiseller_api_key),
"seller_id": settings.digiseller_seller_id,
"api_key_set": bool(settings.digiseller_api_key),
"api_key_hint": mask_secret(settings.digiseller_api_key),
"product_ids": ",".join(str(x) for x in settings.digiseller_product_ids),
"pay_url": settings.digiseller_pay_url,
"credit_from": settings.digiseller_credit_from or "cnt_goods",
"callback_url": settings.digiseller_callback_url,
},
"stars": {
"enabled": bool(settings.stars_enabled),
"rub_per_star": settings.stars_rub_per_star,
},
"topup_code_pay_url": settings.topup_code_pay_url,
}
def _is_masked_placeholder(value: str) -> bool:
v = (value or "").strip()
if not v:
return True
return "" in v or "" in v or "..." in v
def apply_payments_body(body: dict[str, Any]) -> Settings:
"""Применить тело POST админки: .env + os.environ + новый Settings."""
updates: dict[str, str | None] = {}
mapping = {
"CRYPTO_BOT_TOKEN": body.get("crypto_bot_token"),
"CRYPTO_PAY_TESTNET": body.get("crypto_pay_testnet"),
"HELEKET_MERCHANT_ID": body.get("heleket_merchant_id"),
"HELEKET_API_KEY": body.get("heleket_api_key"),
"HELEKET_CALLBACK_URL": body.get("heleket_callback_url"),
"HELEKET_CURRENCY": body.get("heleket_currency"),
"LAVA_SHOP_ID": body.get("lava_shop_id"),
"LAVA_SECRET_KEY": body.get("lava_secret_key"),
"LAVA_ADDITIONAL_KEY": body.get("lava_additional_key"),
"LAVA_HOOK_URL": body.get("lava_hook_url"),
"LAVA_SUCCESS_URL": body.get("lava_success_url"),
"LAVA_FAIL_URL": body.get("lava_fail_url"),
"LAVA_EXPIRE_MINUTES": body.get("lava_expire_minutes"),
"ANYPAY_API_ID": body.get("anypay_api_id"),
"ANYPAY_API_KEY": body.get("anypay_api_key"),
"ANYPAY_PROJECT_ID": body.get("anypay_project_id"),
"ANYPAY_CURRENCY": body.get("anypay_currency"),
"ANYPAY_METHOD": body.get("anypay_method"),
"ANYPAY_SUCCESS_URL": body.get("anypay_success_url"),
"ANYPAY_FAIL_URL": body.get("anypay_fail_url"),
"ANYPAY_EMAIL": body.get("anypay_email"),
"ANYPAY_CHECK_IP": body.get("anypay_check_ip"),
"DIGISELLER_SELLER_ID": body.get("digiseller_seller_id"),
"DIGISELLER_API_KEY": body.get("digiseller_api_key"),
"DIGISELLER_PRODUCT_IDS": body.get("digiseller_product_ids"),
"DIGISELLER_PAY_URL": body.get("digiseller_pay_url"),
"DIGISELLER_CREDIT_FROM": body.get("digiseller_credit_from"),
"DIGISELLER_CALLBACK_URL": body.get("digiseller_callback_url"),
"STARS_ENABLED": body.get("stars_enabled"),
"STARS_RUB_PER_STAR": body.get("stars_rub_per_star"),
"TOPUP_CODE_PAY_URL": body.get("topup_code_pay_url"),
}
for env_key, raw in mapping.items():
if env_key not in PAYMENT_ENV_KEYS:
continue
if raw is None:
continue
if env_key in BOOL_ENV_KEYS:
if isinstance(raw, bool):
updates[env_key] = "true" if raw else "false"
else:
updates[env_key] = (
"true"
if str(raw).strip().lower() in {"1", "true", "yes", "on"}
else "false"
)
continue
text = str(raw).strip()
if env_key in SECRET_ENV_KEYS:
if text == "__clear__":
updates[env_key] = None
elif _is_masked_placeholder(text):
continue
else:
updates[env_key] = text
continue
updates[env_key] = text if text else None
if updates:
upsert_env_keys(updates)
return get_settings(require_bot_token=False)
+284
View File
@@ -0,0 +1,284 @@
"""Бэкап / восстановление PostgreSQL через pg_dump и psql."""
from __future__ import annotations
import asyncio
import gzip
import logging
import os
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
logger = logging.getLogger(__name__)
BACKUP_DIR = Path(os.getenv("BACKUP_DIR") or "/app/backups")
_SAFE_NAME = re.compile(r"^vpnbot-[\w.\-]+\.sql\.gz$", re.I)
_UPLOAD_NAME = re.compile(r"^[\w.\-]+\.sql(\.gz)?$", re.I)
def _pg_env() -> tuple[str, str, str, str, str]:
user = os.getenv("POSTGRES_USER") or "vpnbot"
dbname = os.getenv("POSTGRES_DB") or "vpnbot"
password = os.getenv("POSTGRES_PASSWORD") or "vpnbot"
host = os.getenv("POSTGRES_HOST") or "db"
port = os.getenv("POSTGRES_PORT") or "5432"
return user, dbname, password, host, port
def resolve_backup_path(name: str) -> Path:
"""Безопасный путь к файлу бэкапа (без path traversal)."""
base = Path(name).name
if not _SAFE_NAME.match(base):
raise ValueError("Некорректное имя файла бэкапа")
path = (BACKUP_DIR / base).resolve()
root = BACKUP_DIR.resolve()
if path.parent != root or not path.is_file():
raise FileNotFoundError("Бэкап не найден")
return path
def delete_backup(name: str) -> None:
path = resolve_backup_path(name)
path.unlink()
async def _run_pg_dump_to_host() -> bytes:
"""pg_dump по сети Docker к сервису db."""
user, dbname, password, host, port = _pg_env()
pg_dump = shutil.which("pg_dump")
if not pg_dump:
raise FileNotFoundError(
"pg_dump не найден. Пересоберите образ: docker compose up -d --build bot"
)
env = os.environ.copy()
env["PGPASSWORD"] = password
proc = await asyncio.create_subprocess_exec(
pg_dump,
"-h",
host,
"-p",
port,
"-U",
user,
"-d",
dbname,
"--no-owner",
"--no-acl",
"--clean",
"--if-exists",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError((stderr or b"").decode("utf-8", "replace")[:500] or "pg_dump failed")
if not stdout:
raise RuntimeError("pg_dump вернул пустой дамп")
return stdout
async def _run_pg_dump_via_docker(compose_dir: Path | None) -> bytes:
user, dbname, password, _host, _port = _pg_env()
if not shutil.which("docker"):
raise FileNotFoundError("docker не найден")
cmd = [
"docker",
"compose",
"exec",
"-T",
"-e",
f"PGPASSWORD={password}",
"db",
"pg_dump",
"-U",
user,
"-d",
dbname,
"--no-owner",
"--no-acl",
"--clean",
"--if-exists",
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(compose_dir) if compose_dir else None,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError((stderr or b"").decode("utf-8", "replace")[:500] or "docker pg_dump failed")
if not stdout:
raise RuntimeError("docker pg_dump вернул пустой дамп")
return stdout
async def run_pg_dump_backup(
*,
compose_dir: Path | None = None,
keep: int = 14,
) -> Path:
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
out = BACKUP_DIR / f"vpnbot-{stamp}.sql.gz"
errors: list[str] = []
stdout: bytes | None = None
try:
stdout = await _run_pg_dump_to_host()
except Exception as exc: # noqa: BLE001
errors.append(f"network pg_dump: {exc}")
logger.warning("backup via host pg_dump failed: %s", exc)
if stdout is None:
try:
stdout = await _run_pg_dump_via_docker(compose_dir)
except Exception as exc: # noqa: BLE001
errors.append(f"docker pg_dump: {exc}")
logger.warning("backup via docker failed: %s", exc)
if stdout is None:
raise RuntimeError(
"Не удалось сделать бэкап. "
+ " | ".join(errors)
+ ". Убедитесь, что bot пересобран с postgresql-client."
)
with gzip.open(out, "wb") as fh:
fh.write(stdout)
_prune(BACKUP_DIR, keep=keep)
logger.info("postgres backup written %s (%s bytes)", out, len(stdout))
return out
def _prune(directory: Path, *, keep: int) -> None:
files = sorted(
directory.glob("vpnbot-*.sql.gz"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
for old in files[max(0, keep) :]:
try:
old.unlink()
except OSError:
pass
def list_backups(limit: int = 30) -> list[dict]:
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
files = sorted(
BACKUP_DIR.glob("vpnbot-*.sql.gz"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
out = []
for p in files[:limit]:
st = p.stat()
out.append(
{
"name": p.name,
"size": st.st_size,
"mtime": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
}
)
return out
def _gunzip_if_needed(raw: bytes, *, filename: str = "") -> bytes:
name = (filename or "").lower()
if name.endswith(".gz") or raw[:2] == b"\x1f\x8b":
return gzip.decompress(raw)
return raw
async def _run_psql_sql(sql: bytes) -> None:
user, dbname, password, host, port = _pg_env()
psql = shutil.which("psql")
if not psql:
raise FileNotFoundError(
"psql не найден. Пересоберите образ: docker compose up -d --build bot"
)
# Чистый restore: рвём сессии и пересоздаём schema public,
# иначе старые дампы без DROP падают на «relation already exists».
safe_user = user.replace('"', "")
pre = (
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
f"WHERE datname = '{dbname}' AND pid <> pg_backend_pid();\n"
"DROP SCHEMA IF EXISTS public CASCADE;\n"
"CREATE SCHEMA public;\n"
f'GRANT ALL ON SCHEMA public TO "{safe_user}";\n'
"GRANT ALL ON SCHEMA public TO public;\n"
f'ALTER SCHEMA public OWNER TO "{safe_user}";\n'
).encode("utf-8")
env = os.environ.copy()
env["PGPASSWORD"] = password
proc = await asyncio.create_subprocess_exec(
psql,
"-h",
host,
"-p",
port,
"-U",
user,
"-d",
dbname,
"-v",
"ON_ERROR_STOP=1",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
_stdout, stderr = await proc.communicate(pre + sql)
if proc.returncode != 0:
err = (stderr or b"").decode("utf-8", "replace")[:800]
raise RuntimeError(err or "psql restore failed")
logger.info("postgres restore ok (%s bytes sql)", len(sql))
async def restore_from_bytes(raw: bytes, *, filename: str = "") -> None:
if not raw:
raise ValueError("Пустой файл")
if len(raw) > 200 * 1024 * 1024:
raise ValueError("Файл слишком большой (лимит 200 МБ)")
sql = _gunzip_if_needed(raw, filename=filename)
if not sql.strip():
raise ValueError("Дамп пустой после распаковки")
head = sql[:4000].decode("utf-8", "replace").lower()
if (
"create " not in head
and "drop " not in head
and "copy " not in head
and "insert " not in head
):
raise ValueError("Файл не похож на SQL-дамп PostgreSQL")
await _run_psql_sql(sql)
async def restore_from_backup_name(name: str) -> None:
path = resolve_backup_path(name)
raw = path.read_bytes()
await restore_from_bytes(raw, filename=path.name)
async def save_uploaded_backup(raw: bytes, *, original_name: str = "") -> Path:
"""Сохранить загруженный дамп как vpnbot-*.sql.gz на диск."""
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
base = Path(original_name or "upload.sql.gz").name
if not _UPLOAD_NAME.match(base):
base = "upload.sql.gz"
sql = _gunzip_if_needed(raw, filename=base)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
out = BACKUP_DIR / f"vpnbot-{stamp}.sql.gz"
with gzip.open(out, "wb") as fh:
fh.write(sql)
return out
+674
View File
@@ -0,0 +1,674 @@
"""Покупка тарифа: баланс + создание/продление пользователя Remnawave."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from remnawave import RemnawaveSDK
from remnawave.enums import TrafficLimitStrategy, UserStatus
from remnawave.models import CreateUserRequestDto, UpdateUserRequestDto
from remnawave_client import friendly_remnawave_error
from services.db import Database
from services.devices import (
BASE_DEVICES,
DEVICE_PACKS,
EXTRA_DEVICE_PRICE_RUB,
DevicePack,
DeviceUsage,
)
from services.extras_billing import (
effective_extra_devices,
extras_billing_line,
on_extras_purchased,
process_user_extras_billing,
)
from services.tariffs import Tariff
from services.traffic import (
BASE_TRAFFIC_GB,
TrafficPack,
TrafficUsage,
ensure_base_limit_bytes,
format_gb,
gb_to_bytes,
traffic_from_panel_user,
)
from services.remnawave_users import (
device_limit_for_user as _device_limit_for_user,
find_panel_user as _find_panel_user,
sync_device_policy,
username_for,
)
logger = logging.getLogger(__name__)
def _as_utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _traffic_limit_for_update(existing, tariff: Tariff) -> float:
"""При продлении не затираем докупленный трафик, но не меньше базы тарифа."""
current = ensure_base_limit_bytes(getattr(existing, "traffic_limit_bytes", 0))
return max(current, float(tariff.traffic_bytes))
async def provision_subscription(
sdk: RemnawaveSDK,
*,
telegram_id: int,
tariff: Tariff,
login: str | None = None,
extra_devices: int = 0,
) -> tuple[str, str, datetime]:
"""Создать или продлить пользователя. Возвращает (uuid, sub_url, expire_at)."""
now = datetime.now(timezone.utc)
existing = await _find_panel_user(sdk, telegram_id, login=login)
panel_tg_id = telegram_id if telegram_id > 0 else None
device_limit = _device_limit_for_user(extra_devices, soft_cap=True)
if existing is not None:
current_expire = _as_utc(existing.expire_at)
base = current_expire if current_expire > now else now
new_expire = base + timedelta(days=tariff.days)
body = UpdateUserRequestDto(
uuid=existing.uuid,
status=UserStatus.ACTIVE,
expire_at=new_expire,
telegram_id=panel_tg_id,
hwid_device_limit=device_limit,
traffic_limit_bytes=_traffic_limit_for_update(existing, tariff),
traffic_limit_strategy=TrafficLimitStrategy.NO_RESET,
active_internal_squads=list(tariff.squad_uuids) or None,
)
updated = await sdk.users.update_user(body)
return str(updated.uuid), updated.subscription_url, _as_utc(updated.expire_at)
new_expire = now + timedelta(days=tariff.days)
created = await sdk.users.create_user(
CreateUserRequestDto(
username=username_for(telegram_id, login=login),
expire_at=new_expire,
status=UserStatus.ACTIVE,
telegram_id=panel_tg_id,
hwid_device_limit=device_limit,
traffic_limit_bytes=float(tariff.traffic_bytes),
traffic_limit_strategy=TrafficLimitStrategy.NO_RESET,
active_internal_squads=list(tariff.squad_uuids) or None,
description=f"Bought via bot: {tariff.id}",
tag="BOT",
)
)
return str(created.uuid), created.subscription_url, _as_utc(created.expire_at)
async def purchase_tariff(
*,
db: Database,
sdk: RemnawaveSDK,
telegram_id: int,
tariff: Tariff,
charge_balance: bool = True,
payment_meta: str | None = None,
renew: bool = False,
promo_code: str | None = None,
) -> PurchaseResult:
bot_user = db.ensure_user(telegram_id)
if bot_user.banned:
return PurchaseResult(
ok=False,
message="Аккаунт заблокирован. Обратитесь в поддержку.",
balance=bot_user.balance,
)
balance = bot_user.balance
login = bot_user.login
if renew:
existing = await _find_panel_user(sdk, telegram_id, login=login)
if existing is None:
return PurchaseResult(
ok=False,
message=(
"Нет подписки для продления.\n"
"Сначала купи тариф в разделе «Тарифы»."
),
balance=balance,
)
charge_price = float(tariff.price)
bonus_days = 0
promo_row = None
if promo_code and charge_balance:
from services.db import AuthError
try:
charge_price, bonus_days, promo_row = db.apply_promo_to_price(
code=promo_code,
telegram_id=telegram_id,
tariff_id=tariff.id,
price=float(tariff.price),
days=tariff.days,
)
except AuthError as exc:
return PurchaseResult(ok=False, message=str(exc), balance=balance)
effective_tariff = tariff
if bonus_days > 0:
effective_tariff = Tariff(
id=tariff.id,
title=tariff.title,
price=tariff.price,
days=tariff.days + bonus_days,
traffic_gb=tariff.traffic_gb,
device_limit=tariff.device_limit,
squad_uuids=tariff.squad_uuids,
description=tariff.description,
)
if charge_balance:
if charge_price > 0:
ok, balance = db.try_charge(
telegram_id,
charge_price,
kind="renewal" if renew else "purchase",
meta=payment_meta
or (
f"promo:{promo_row['code']}:" if promo_row else ""
)
+ ("renew:" + tariff.id if renew else tariff.id),
)
if not ok:
return PurchaseResult(
ok=False,
message=(
f"Недостаточно средств.\n"
f"Нужно: <b>{charge_price:.0f} ₽</b>\n"
f"Баланс: <b>{balance:.0f} ₽</b>\n\n"
"Пополни баланс криптой или через админа."
),
balance=balance,
)
# charge_price == 0 — бесплатно по промо
try:
rw_uuid, sub_url, expire_at = await provision_subscription(
sdk,
telegram_id=telegram_id,
tariff=effective_tariff,
login=login,
extra_devices=effective_extra_devices(bot_user),
)
except Exception as exc: # noqa: BLE001
if charge_balance and charge_price > 0:
balance = db.add_balance(
telegram_id,
charge_price,
kind="refund",
meta=f"refund:{tariff.id}:{exc}",
)
return PurchaseResult(
ok=False,
message=(
"Не удалось продлить/выдать подписку. Средства возвращены.\n\n"
+ friendly_remnawave_error(exc)
),
balance=balance,
)
return PurchaseResult(
ok=False,
message=(
"Оплата прошла, но выдача подписки упала. Напиши админу.\n\n"
+ friendly_remnawave_error(exc)
),
balance=balance,
)
if promo_row and charge_balance:
saved = max(0.0, float(tariff.price) - charge_price)
try:
db.redeem_promo(
promo_id=int(promo_row["id"]),
telegram_id=telegram_id,
amount_saved=saved,
)
except Exception: # noqa: BLE001
logger.exception("promo redeem failed")
db.record_purchase(
telegram_id,
tariff_id=tariff.id,
price=charge_price if charge_balance else float(tariff.price),
remnawave_uuid=rw_uuid,
subscription_url=sub_url,
)
if not renew and charge_balance:
try:
ref_bonus = float(db.get_setting("referral_bonus_referrer") or "50")
welcome = float(db.get_setting("referral_bonus_referee") or "50")
db.grant_referral_rewards_on_first_purchase(
telegram_id,
referrer_bonus=max(0.0, ref_bonus),
referee_bonus=max(0.0, welcome),
)
except Exception: # noqa: BLE001
logger.exception("referral reward failed")
try:
from services.xui_wg import sync_wg_configs_limits
from services.awg_configs import sync_awg_configs_expiry
await sync_wg_configs_limits(
db=db,
telegram_id=telegram_id,
expire_at=expire_at,
restore_enabled=True,
)
await sync_awg_configs_expiry(
db=db,
sdk=sdk,
telegram_id=telegram_id,
login=login,
restore_enabled=True,
)
except Exception: # noqa: BLE001
logger.exception("WG/AWG expiry sync after tariff purchase failed")
expire_str = expire_at.strftime("%d.%m.%Y %H:%M UTC")
if renew:
paid_how = "с баланса" if charge_balance else "криптой"
return PurchaseResult(
ok=True,
message=(
f"✅ <b>Подписка продлена</b>\n\n"
f"Тариф: <b>{tariff.title}</b> (+{tariff.days} дн.)\n"
f"Списано: <b>{tariff.format_price()}</b> ({paid_how})\n"
f"Баланс: <b>{balance:.0f} ₽</b>\n"
f"Новый срок: <b>{expire_str}</b>\n\n"
f"🔗 Подписка:\n<code>{sub_url}</code>"
),
balance=balance,
subscription_url=sub_url,
expire_at=expire_at,
)
paid_how = "с баланса" if charge_balance else "криптой"
return PurchaseResult(
ok=True,
message=(
f"✅ <b>Тариф «{tariff.title}» активирован</b>\n\n"
f"Оплата: <b>{tariff.format_price()}</b> ({paid_how})\n"
f"Баланс: <b>{balance:.0f} ₽</b>\n"
f"Действует до: <b>{expire_str}</b>\n\n"
f"🔗 Подписка:\n<code>{sub_url}</code>"
),
balance=balance,
subscription_url=sub_url,
expire_at=expire_at,
)
@dataclass(frozen=True, slots=True)
class PurchaseResult:
ok: bool
message: str
balance: float
subscription_url: str | None = None
expire_at: datetime | None = None
traffic: TrafficUsage | None = None
devices: DeviceUsage | None = None
@dataclass(frozen=True, slots=True)
class SubscriptionInfo:
text: str
has_subscription: bool
expire_at: datetime | None = None
subscription_url: str | None = None
traffic: TrafficUsage | None = None
devices: DeviceUsage | None = None
async def get_my_subscription_info(
sdk: RemnawaveSDK,
telegram_id: int,
*,
login: str | None = None,
db: Database | None = None,
tariffs: list[Tariff] | None = None,
renewal_bot=None,
) -> SubscriptionInfo:
user = await _find_panel_user(sdk, telegram_id, login=login)
if user is None:
return SubscriptionInfo(
text=(
"<b>Моя подписка</b>\n\n"
"У тебя пока нет подписки.\n"
"Купи тариф в разделе «Тарифы».\n\n"
f"База: <b>{format_gb(BASE_TRAFFIC_GB, digits=0)}</b> · "
f"<b>{BASE_DEVICES} устройств</b>\n"
f"Докупка: +500 ГБ = 50 ₽/мес · +1 устройство = "
f"{int(EXTRA_DEVICE_PRICE_RUB)} ₽/мес\n"
"Доп. опции списываются с баланса каждые 30 дней."
),
has_subscription=False,
)
bot_user = db.ensure_user(telegram_id) if db is not None else None
if db is not None:
await process_user_extras_billing(
db, sdk, telegram_id, login=login
)
if tariffs:
from services.subscription_renewal import (
process_user_subscription_renewal,
)
await process_user_subscription_renewal(
db,
sdk,
tariffs,
telegram_id,
bot=renewal_bot,
login=login,
)
bot_user = db.get_user(telegram_id)
extra_paid = effective_extra_devices(bot_user) if bot_user else 0
device_blocked = bot_user.device_blocked if bot_user else False
# Старые «безлимит» (0) → база 1000 ГБ
if float(getattr(user, "traffic_limit_bytes", 0) or 0) <= 0:
try:
user = await sdk.users.update_user(
UpdateUserRequestDto(
uuid=user.uuid,
traffic_limit_bytes=gb_to_bytes(BASE_TRAFFIC_GB),
traffic_limit_strategy=TrafficLimitStrategy.NO_RESET,
)
)
except Exception: # noqa: BLE001
pass
devices: DeviceUsage | None = None
if db is not None:
user, devices = await sync_device_policy(
sdk,
user,
db=db,
telegram_id=telegram_id,
extra_paid=extra_paid,
device_blocked=device_blocked,
)
expire = _as_utc(user.expire_at)
now = datetime.now(timezone.utc)
if devices and devices.over_limit:
status = "отключена (превышение устройств)"
elif expire > now and user.status == UserStatus.ACTIVE:
status = "активна"
else:
status = "истекла / неактивна"
traffic = traffic_from_panel_user(user)
devices_line = devices.format_line() if devices else f"📱 Устройства: база <b>{BASE_DEVICES}</b>"
extras_line = extras_billing_line(bot_user) if bot_user else ""
text = (
f"<b>Моя подписка</b>\n\n"
f"Статус: <b>{status}</b>\n"
f"До: <b>{expire.strftime('%d.%m.%Y %H:%M UTC')}</b>\n"
f"{devices_line}\n"
f"{traffic.format_line()}\n"
f"{extras_line}\n\n"
f"🔗 <code>{user.subscription_url}</code>\n\n"
f"Докупка: трафик +500 ГБ / <b>50 ₽/мес</b> · устройство +1 / "
f"<b>{int(EXTRA_DEVICE_PRICE_RUB)} ₽/мес</b>"
)
return SubscriptionInfo(
text=text,
has_subscription=True,
expire_at=expire,
subscription_url=user.subscription_url,
traffic=traffic,
devices=devices,
)
async def purchase_device_pack(
*,
db: Database,
sdk: RemnawaveSDK,
telegram_id: int,
pack: DevicePack,
charge_balance: bool = True,
login: str | None = None,
payment_meta: str | None = None,
) -> PurchaseResult:
bot_user = db.ensure_user(telegram_id)
balance = bot_user.balance
login = login or bot_user.login
existing = await _find_panel_user(sdk, telegram_id, login=login)
if existing is None:
return PurchaseResult(
ok=False,
message="Нет подписки. Сначала купи тариф (база 10 устройств).",
balance=balance,
)
if charge_balance:
ok, balance = db.try_charge(
telegram_id,
pack.price,
kind="devices",
meta=payment_meta or f"devices:{pack.id}",
)
if not ok:
return PurchaseResult(
ok=False,
message=(
f"Недостаточно средств.\n"
f"Нужно: <b>{pack.format_price()}</b>\n"
f"Баланс: <b>{balance:.0f} ₽</b>"
),
balance=balance,
)
new_extra = db.add_extra_devices(telegram_id, pack.slots)
on_extras_purchased(db, telegram_id)
try:
updated, devices = await sync_device_policy(
sdk,
existing,
db=db,
telegram_id=telegram_id,
extra_paid=new_extra,
device_blocked=False,
)
except Exception as exc: # noqa: BLE001
if charge_balance:
# откат слотов и денег
with db.pool.connection() as conn:
conn.execute(
"""
UPDATE users
SET extra_devices = GREATEST(extra_devices - %s, 0)
WHERE telegram_id = %s
""",
(pack.slots, telegram_id),
)
conn.commit()
balance = db.add_balance(
telegram_id,
pack.price,
kind="refund",
meta=f"refund:devices:{pack.id}:{exc}",
)
return PurchaseResult(
ok=False,
message=(
"Не удалось обновить лимит устройств. Средства возвращены.\n\n"
+ friendly_remnawave_error(exc)
),
balance=balance,
)
return PurchaseResult(
ok=False,
message="Оплата прошла, но лимит не обновился. Напиши админу.",
balance=balance,
)
return PurchaseResult(
ok=True,
message=(
f"✅ <b>Лимит устройств увеличен</b>\n\n"
f"Добавлено: <b>+{pack.slots}</b>\n"
f"Списано: <b>{pack.format_price()}</b> (первый месяц)\n"
f"Далее — <b>{int(EXTRA_DEVICE_PRICE_RUB)} ₽/мес</b> за каждое доп. устройство с баланса.\n"
f"Баланс: <b>{balance:.0f} ₽</b>\n"
f"{devices.format_line()}"
),
balance=balance,
subscription_url=getattr(updated, "subscription_url", None),
devices=devices,
)
async def purchase_traffic_pack(
*,
db: Database,
sdk: RemnawaveSDK,
telegram_id: int,
pack: TrafficPack,
charge_balance: bool = True,
login: str | None = None,
payment_meta: str | None = None,
) -> PurchaseResult:
bot_user = db.ensure_user(telegram_id)
balance = bot_user.balance
login = login or bot_user.login
existing = await _find_panel_user(sdk, telegram_id, login=login)
if existing is None:
return PurchaseResult(
ok=False,
message=(
"Нет активной подписки.\n"
"Сначала купи тариф — база 1000 ГБ уже входит."
),
balance=balance,
)
if charge_balance:
ok, balance = db.try_charge(
telegram_id,
pack.price,
kind="traffic",
meta=payment_meta or f"traffic:{pack.id}",
)
if not ok:
return PurchaseResult(
ok=False,
message=(
f"Недостаточно средств.\n"
f"Нужно: <b>{pack.format_price()}</b> (первый месяц)\n"
f"Баланс: <b>{balance:.0f} ₽</b>"
),
balance=balance,
)
db.add_extra_traffic_packs(telegram_id, pack.packs)
on_extras_purchased(db, telegram_id)
current_limit = ensure_base_limit_bytes(existing.traffic_limit_bytes)
new_limit = current_limit + gb_to_bytes(pack.gb)
try:
updated = await sdk.users.update_user(
UpdateUserRequestDto(
uuid=existing.uuid,
traffic_limit_bytes=new_limit,
traffic_limit_strategy=TrafficLimitStrategy.NO_RESET,
status=UserStatus.ACTIVE,
)
)
except Exception as exc: # noqa: BLE001
with db.pool.connection() as conn:
conn.execute(
"""
UPDATE users
SET extra_traffic_packs = GREATEST(extra_traffic_packs - %s, 0)
WHERE telegram_id = %s
""",
(pack.packs, telegram_id),
)
conn.commit()
if charge_balance:
balance = db.add_balance(
telegram_id,
pack.price,
kind="refund",
meta=f"refund:traffic:{pack.id}:{exc}",
)
return PurchaseResult(
ok=False,
message=(
"Не удалось увеличить трафик. Средства возвращены.\n\n"
+ friendly_remnawave_error(exc)
),
balance=balance,
)
return PurchaseResult(
ok=False,
message="Оплата прошла, но лимит не обновился. Напиши админу.\n\n"
+ friendly_remnawave_error(exc),
balance=balance,
)
traffic = traffic_from_panel_user(updated)
try:
from services.xui_wg import sync_wg_configs_limits
from services.awg_configs import sync_awg_configs_expiry
await sync_wg_configs_limits(
db=db,
telegram_id=telegram_id,
traffic_bytes=int(new_limit),
expire_at=_as_utc(updated.expire_at) if updated.expire_at else None,
restore_enabled=True,
)
await sync_awg_configs_expiry(
db=db,
sdk=sdk,
telegram_id=telegram_id,
login=login,
restore_enabled=True,
)
except Exception: # noqa: BLE001
logger.exception("WG/AWG sync after pack purchase failed")
return PurchaseResult(
ok=True,
message=(
f"✅ <b>Трафик увеличен</b>\n\n"
f"Добавлено: <b>{format_gb(pack.gb, digits=0)}</b>\n"
f"Списано: <b>{pack.format_price()}</b> (первый месяц)\n"
f"Далее — <b>{int(pack.price / pack.packs)} ₽/мес</b> за каждые 500 ГБ с баланса.\n"
f"Баланс: <b>{balance:.0f} ₽</b>\n"
f"{traffic.format_line()}"
),
balance=balance,
subscription_url=updated.subscription_url,
expire_at=_as_utc(updated.expire_at) if updated.expire_at else None,
traffic=traffic,
)
async def get_my_subscription(sdk: RemnawaveSDK, telegram_id: int) -> str:
return (await get_my_subscription_info(sdk, telegram_id)).text
+26
View File
@@ -0,0 +1,26 @@
"""Генерация QR (SVG) без Pillow — только пакет qrcode."""
from __future__ import annotations
import io
import qrcode
import qrcode.image.svg
def make_qr_svg(data: str, *, scale: int = 4, border: int = 2) -> bytes:
"""Возвращает SVG QR как bytes (image/svg+xml)."""
text = (data or "").strip()
if not text:
raise ValueError("empty qr data")
factory = qrcode.image.svg.SvgPathImage
img = qrcode.make(
text,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=max(2, int(scale)),
border=max(1, int(border)),
image_factory=factory,
)
buf = io.BytesIO()
img.save(buf)
return buf.getvalue()
+41
View File
@@ -0,0 +1,41 @@
"""Простой in-memory rate limit (на процесс)."""
from __future__ import annotations
import time
from collections import defaultdict
from threading import Lock
class RateLimiter:
def __init__(self) -> None:
self._hits: dict[str, list[float]] = defaultdict(list)
self._lock = Lock()
def hit(self, key: str, *, limit: int, window_sec: float) -> bool:
"""True если запрос разрешён, False если превышен лимит."""
now = time.monotonic()
with self._lock:
bucket = self._hits[key]
cutoff = now - window_sec
self._hits[key] = [t for t in bucket if t >= cutoff]
if len(self._hits[key]) >= limit:
return False
self._hits[key].append(now)
return True
limiter = RateLimiter()
def client_ip(request) -> str:
forwarded = (request.headers.get("X-Forwarded-For") or "").split(",")[0].strip()
if forwarded:
return forwarded
peer = request.remote
return str(peer or "unknown")
def allow(request, bucket: str, *, limit: int = 20, window_sec: float = 60.0) -> bool:
ip = client_ip(request)
return limiter.hit(f"{bucket}:{ip}", limit=limit, window_sec=window_sec)
+115
View File
@@ -0,0 +1,115 @@
"""Remnawave: поиск пользователя и синхронизация лимита устройств."""
from __future__ import annotations
from datetime import datetime, timezone
from remnawave import RemnawaveSDK
from remnawave.enums import UserStatus
from remnawave.models import UpdateUserRequestDto
from services.db import Database
from services.devices import (
BASE_DEVICES,
PANEL_SOFT_CAP,
DeviceUsage,
allowed_devices,
)
def as_utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def username_for(telegram_id: int, *, login: str | None = None) -> str:
if telegram_id > 0:
return f"tg_{telegram_id}"
if login:
safe = "".join(ch for ch in login if ch.isalnum() or ch == "_")[:24] or "user"
return f"web_{safe}"
return f"web_{abs(telegram_id)}"
async def find_panel_user(
sdk: RemnawaveSDK, telegram_id: int, *, login: str | None = None
):
if telegram_id > 0:
try:
users = await sdk.users.get_users_by_telegram_id(str(telegram_id))
if users:
return users[0]
except Exception: # noqa: BLE001
pass
try:
return await sdk.users.get_user_by_username(
username_for(telegram_id, login=login)
)
except Exception: # noqa: BLE001
return None
def device_limit_for_user(extra_paid: int, *, soft_cap: bool = True) -> int:
allowed = allowed_devices(extra_paid)
if soft_cap:
return max(allowed, min(PANEL_SOFT_CAP, max(allowed * 2, BASE_DEVICES + 20)))
return allowed
async def count_hwid_devices(sdk: RemnawaveSDK, user_uuid: str) -> int:
try:
resp = await sdk.hwid.get_hwid_user(str(user_uuid))
return int(getattr(resp, "total", None) or len(getattr(resp, "devices", []) or []))
except Exception: # noqa: BLE001
return 0
async def sync_device_policy(
sdk: RemnawaveSDK,
panel_user,
*,
db: Database,
telegram_id: int,
extra_paid: int,
device_blocked: bool,
) -> tuple[object, DeviceUsage]:
"""Синхронизация лимита HWID и отключение при превышении оплаченной квоты."""
used = await count_hwid_devices(sdk, str(panel_user.uuid))
allowed = allowed_devices(extra_paid)
over = used > allowed
soft_limit = device_limit_for_user(extra_paid, soft_cap=True)
updates: dict = {
"uuid": panel_user.uuid,
"hwid_device_limit": soft_limit,
}
if over:
updates["status"] = UserStatus.DISABLED
if not device_blocked:
db.set_device_blocked(telegram_id, True)
elif device_blocked:
expire = as_utc(panel_user.expire_at)
if expire > datetime.now(timezone.utc):
updates["status"] = UserStatus.ACTIVE
db.set_device_blocked(telegram_id, False)
try:
current_limit = int(getattr(panel_user, "hwid_device_limit", 0) or 0)
current_status = panel_user.status
need_update = current_limit != soft_limit
if "status" in updates and updates["status"] != current_status:
need_update = True
if need_update:
panel_user = await sdk.users.update_user(UpdateUserRequestDto(**updates))
except Exception: # noqa: BLE001
pass
usage = DeviceUsage(
used=used,
allowed=allowed,
extra_paid=max(0, int(extra_paid)),
over_limit=over,
)
return panel_user, usage
+90
View File
@@ -0,0 +1,90 @@
"""SMTP runtime: публичный payload и запись в .env из админки."""
from __future__ import annotations
from typing import Any
from config import Settings, get_settings
from services.env_file import (
BOOL_ENV_KEYS,
SECRET_ENV_KEYS,
env_file_path,
env_writable,
mask_secret,
upsert_env_keys,
)
from services.mailer import smtp_configured
SMTP_ENV_KEYS: tuple[str, ...] = (
"SMTP_HOST",
"SMTP_PORT",
"SMTP_USER",
"SMTP_PASSWORD",
"SMTP_FROM",
"SMTP_TLS",
"SMTP_SSL",
)
def smtp_public_dict(settings: Settings) -> dict[str, Any]:
path = env_file_path()
return {
"env_path": str(path),
"env_writable": env_writable(path),
"configured": smtp_configured(settings),
"host": settings.smtp_host,
"port": settings.smtp_port,
"user": settings.smtp_user,
"password_set": bool(settings.smtp_password),
"password_hint": mask_secret(settings.smtp_password),
"from_email": settings.smtp_from,
"tls": bool(settings.smtp_tls),
"ssl": bool(settings.smtp_ssl),
}
def _is_masked_placeholder(value: str) -> bool:
v = (value or "").strip()
if not v:
return True
return "" in v or "" in v or "..." in v
def apply_smtp_body(body: dict[str, Any]) -> Settings:
updates: dict[str, str | None] = {}
mapping = {
"SMTP_HOST": body.get("smtp_host"),
"SMTP_PORT": body.get("smtp_port"),
"SMTP_USER": body.get("smtp_user"),
"SMTP_PASSWORD": body.get("smtp_password"),
"SMTP_FROM": body.get("smtp_from"),
"SMTP_TLS": body.get("smtp_tls"),
"SMTP_SSL": body.get("smtp_ssl"),
}
for env_key, raw in mapping.items():
if raw is None:
continue
if env_key in BOOL_ENV_KEYS:
if isinstance(raw, bool):
updates[env_key] = "true" if raw else "false"
else:
updates[env_key] = (
"true"
if str(raw).strip().lower() in {"1", "true", "yes", "on"}
else "false"
)
continue
text = str(raw).strip()
if env_key in SECRET_ENV_KEYS:
if text == "__clear__":
updates[env_key] = None
elif _is_masked_placeholder(text):
continue
else:
updates[env_key] = text
continue
updates[env_key] = text if text else None
if updates:
upsert_env_keys(updates, allowlist=SMTP_ENV_KEYS)
return get_settings(require_bot_token=False)
+135
View File
@@ -0,0 +1,135 @@
"""Telegram Stars: курс, пакеты и создание инвойсов."""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Any
from aiogram.types import LabeledPrice
from services.db import Database
# Суммы пополнения в рублях (как у крипты)
STAR_TOPUP_AMOUNTS_RUB: tuple[int, ...] = (100, 200, 500, 1000, 2000)
@dataclass(frozen=True, slots=True)
class StarsQuote:
amount_rub: float
stars: int
rub_per_star: float
@property
def stars_label(self) -> str:
return f"{self.stars}"
@property
def rub_label(self) -> str:
if self.amount_rub == int(self.amount_rub):
return f"{int(self.amount_rub)}"
return f"{self.amount_rub:.2f}"
def rub_to_stars(amount_rub: float, *, rub_per_star: float) -> int:
rate = float(rub_per_star)
if rate <= 0:
raise ValueError("rub_per_star must be > 0")
stars = int(math.ceil(float(amount_rub) / rate))
return max(1, stars)
def quote_topup(amount_rub: float, *, rub_per_star: float) -> StarsQuote:
amount = float(amount_rub)
if amount <= 0:
raise ValueError("amount_rub must be > 0")
stars = rub_to_stars(amount, rub_per_star=rub_per_star)
return StarsQuote(amount_rub=amount, stars=stars, rub_per_star=float(rub_per_star))
def parse_stars_payload(payload: str) -> tuple[int, int, float] | None:
"""
payload: stars:{invoice_id}:{telegram_id}:{amount_rub}
(invoice_id, telegram_id, amount_rub)
"""
parts = (payload or "").split(":")
if len(parts) != 4 or parts[0] != "stars":
return None
try:
return int(parts[1]), int(parts[2]), float(parts[3])
except (TypeError, ValueError):
return None
def build_stars_payload(*, invoice_id: int, telegram_id: int, amount_rub: float) -> str:
return f"stars:{invoice_id}:{telegram_id}:{int(amount_rub)}"
def packs_payload(*, rub_per_star: float) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for amount in STAR_TOPUP_AMOUNTS_RUB:
q = quote_topup(amount, rub_per_star=rub_per_star)
out.append(
{
"amount_rub": q.amount_rub,
"stars": q.stars,
"rub_label": q.rub_label,
"stars_label": q.stars_label,
"title": f"{q.rub_label} · {q.stars_label}",
}
)
return out
async def create_stars_topup(
*,
bot: Any,
db: Database,
telegram_id: int,
amount_rub: float,
rub_per_star: float,
) -> dict[str, Any]:
"""Создать локальный инвойс + invoice link для Mini App / Telegram Web."""
if telegram_id <= 0:
raise ValueError("Stars доступны только для Telegram-аккаунта")
quote = quote_topup(amount_rub, rub_per_star=rub_per_star)
local_id = db.next_local_invoice_id()
payload = build_stars_payload(
invoice_id=local_id,
telegram_id=telegram_id,
amount_rub=quote.amount_rub,
)
invoice_link = await bot.create_invoice_link(
title="Пополнение баланса",
description=(
f"Зачисление {quote.rub_label} на баланс VPN Service "
f"({quote.stars_label})"
),
payload=payload,
currency="XTR",
prices=[
LabeledPrice(
label=f"Баланс {quote.rub_label}",
amount=quote.stars,
)
],
provider_token="",
)
db.create_invoice_record(
telegram_id=telegram_id,
invoice_id=local_id,
kind="topup",
amount_rub=quote.amount_rub,
pay_url=invoice_link,
payload=payload,
provider="stars",
)
return {
"invoice_id": local_id,
"invoice_link": invoice_link,
"amount_rub": quote.amount_rub,
"stars": quote.stars,
"rub_label": quote.rub_label,
"stars_label": quote.stars_label,
"payload": payload,
}
+254
View File
@@ -0,0 +1,254 @@
"""Напоминания об окончании подписки и автопродление с баланса."""
from __future__ import annotations
import asyncio
import logging
from datetime import date, datetime, timedelta, timezone
from aiogram import Bot
from remnawave import RemnawaveSDK
from config import get_settings
from services.db import Database
from services.mailer import notify_important
from services.remnawave_users import find_panel_user
from services.purchase import purchase_tariff
from services.tariffs import Tariff, get_tariff
logger = logging.getLogger(__name__)
REMIND_WITHIN_DAYS = 7
# Автопродление: в последние сутки или уже истекло (до 3 суток простоя)
AUTO_RENEW_MAX_PAST_DAYS = 3
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _as_utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _days_left(expire_at: datetime) -> float:
return (_as_utc(expire_at) - _utc_now()).total_seconds() / 86400.0
def _utc_today() -> date:
return _utc_now().date()
async def _notify(
bot: Bot | None,
telegram_id: int,
text: str,
*,
db: Database | None = None,
subject: str = "VPN Service",
) -> None:
if db is not None:
try:
settings = get_settings(require_bot_token=False)
except SystemExit:
settings = None
if settings is not None:
await notify_important(
db=db,
settings=settings,
telegram_id=telegram_id,
html=text,
subject=subject,
bot=bot,
)
return
if bot is None or telegram_id <= 0:
return
try:
await bot.send_message(telegram_id, text, parse_mode="HTML")
except Exception: # noqa: BLE001
logger.warning("renewal notify failed tg=%s", telegram_id)
def _format_days_left(days: float) -> str:
if days < 0:
n = int(abs(days))
if n == 0:
return "истекла сегодня"
return f"истекла {n} дн. назад"
if days < 1:
return "менее суток"
n = int(round(days))
if n == 1:
return "1 день"
if 2 <= n <= 4:
return f"{n} дня"
return f"{n} дней"
async def process_user_subscription_renewal(
db: Database,
sdk: RemnawaveSDK,
tariffs: list[Tariff],
telegram_id: int,
*,
bot: Bot | None = None,
login: str | None = None,
) -> None:
if telegram_id <= 0:
return
bot_user = db.get_user(telegram_id)
login = login or (bot_user.login if bot_user else None)
panel_user = await find_panel_user(sdk, telegram_id, login=login)
if panel_user is None or not panel_user.expire_at:
return
expire = _as_utc(panel_user.expire_at)
days = _days_left(expire)
if days > REMIND_WITHIN_DAYS:
return
tariff_id = db.get_last_tariff_id(telegram_id)
if not tariff_id:
return
tariff = get_tariff(tariffs, tariff_id)
if not tariff:
return
balance = db.get_balance(telegram_id)
can_auto = balance >= tariff.price
bot_user = db.get_user(telegram_id)
device_blocked = bool(bot_user and bot_user.device_blocked)
auto_renew_on = bool(bot_user.auto_renew) if bot_user else True
# Автопродление — только если пользователь включил опцию
should_auto = days <= 1.0 or (
days < 0 and days >= -AUTO_RENEW_MAX_PAST_DAYS
)
if should_auto and auto_renew_on and can_auto and not device_blocked:
result = await purchase_tariff(
db=db,
sdk=sdk,
telegram_id=telegram_id,
tariff=tariff,
charge_balance=True,
renew=True,
payment_meta=f"auto_renew:{tariff.id}",
)
if result.ok:
await _notify(
bot,
telegram_id,
(
"<b>✅ Подписка продлена автоматически</b>\n\n"
f"Тариф: <b>{tariff.title}</b>\n"
f"Списано: <b>{tariff.format_price()}</b> с баланса\n"
f"Баланс: <b>{result.balance:.0f} ₽</b>\n\n"
"Отключить автопродление можно в кабинете в любой момент."
),
db=db,
subject="Подписка продлена — VPN Service",
)
db.mark_expiry_reminder_sent(telegram_id, _utc_today())
return
logger.info(
"auto renew failed tg=%s: %s",
telegram_id,
result.message[:120],
)
# Напоминание раз в сутки (UTC)
if db.expiry_reminder_sent_on(telegram_id) == _utc_today():
return
expire_label = expire.strftime("%d.%m.%Y %H:%M UTC")
left_label = _format_days_left(days)
lines = [
"<b>⏳ Подписка скоро закончится</b>\n",
f"Осталось: <b>{left_label}</b>\n",
f"Действует до: <b>{expire_label}</b>\n",
f"Тариф для продления: <b>{tariff.title}</b> · {tariff.format_price()}\n",
f"Баланс: <b>{balance:.0f} ₽</b>\n",
]
if auto_renew_on and can_auto and days <= 1.0:
lines.append(
"\nАвтопродление включено — при достаточном балансе подписка продлится сама."
)
elif auto_renew_on and can_auto:
lines.append(
f"\nАвтопродление включено. За сутки до окончания спишем "
f"<b>{tariff.format_price()}</b> с баланса."
)
elif auto_renew_on and device_blocked:
lines.append(
"\n⚠️ Подписка отключена из‑за превышения устройств — "
"сначала оплати доп. слоты или отвяжи лишние устройства."
)
elif auto_renew_on:
need = max(0.0, tariff.price - balance)
lines.append(
f"\nПополни баланс минимум на <b>{need:.0f} ₽</b> "
f"для автопродления или продли вручную в боте / Mini App."
)
else:
lines.append(
"\nАвтопродление выключено. Включите в кабинете или продлите тариф вручную."
)
await _notify(
bot,
telegram_id,
"".join(lines),
db=db,
subject="Подписка скоро закончится — VPN Service",
)
db.mark_expiry_reminder_sent(telegram_id, _utc_today())
async def run_subscription_renewal_cycle(
db: Database,
sdk: RemnawaveSDK,
tariffs: list[Tariff],
*,
bot: Bot | None = None,
limit: int = 500,
) -> int:
ids = db.list_telegram_ids_with_purchases(limit=limit)
done = 0
for tid in ids:
try:
await process_user_subscription_renewal(
db, sdk, tariffs, tid, bot=bot
)
done += 1
except Exception: # noqa: BLE001
logger.exception("subscription renewal tg=%s", tid)
return done
async def subscription_renewal_worker(
db: Database,
sdk: RemnawaveSDK,
bot: Bot,
tariffs: list[Tariff],
*,
interval_sec: int = 6 * 3600,
) -> None:
"""Проверка несколько раз в сутки; напоминание — не чаще 1 раза в день на пользователя."""
await asyncio.sleep(90)
while True:
try:
n = await run_subscription_renewal_cycle(
db, sdk, tariffs, bot=bot
)
if n:
logger.info("subscription renewal cycle: %s users", n)
except Exception: # noqa: BLE001
logger.exception("subscription renewal cycle error")
await asyncio.sleep(interval_sec)
+188
View File
@@ -0,0 +1,188 @@
"""Тарифы из tariffs.json."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from uuid import UUID
TARIFF_ID_RE = re.compile(r"^[A-Za-z0-9_]{2,40}$")
class TariffError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class Tariff:
id: str
title: str
price: float
days: int
traffic_gb: float
device_limit: int
squad_uuids: tuple[UUID, ...]
description: str = ""
@property
def traffic_bytes(self) -> float:
# 0 или меньше → базовая квота 1000 ГБ
from services.traffic import BASE_TRAFFIC_GB, gb_to_bytes
gb = self.traffic_gb if self.traffic_gb > 0 else BASE_TRAFFIC_GB
return gb_to_bytes(gb)
def format_price(self) -> str:
if self.price == int(self.price):
return f"{int(self.price)}"
return f"{self.price:.2f}"
def to_dict(self) -> dict:
return {
"id": self.id,
"title": self.title,
"price": self.price if self.price != int(self.price) else int(self.price),
"days": self.days,
"traffic_gb": self.traffic_gb,
"device_limit": self.device_limit,
"squad_uuids": [str(u) for u in self.squad_uuids],
"description": self.description,
}
def to_admin_dict(self) -> dict:
d = self.to_dict()
d["price_label"] = self.format_price()
return d
def load_tariffs(path: str | Path) -> list[Tariff]:
data = json.loads(Path(path).read_text(encoding="utf-8"))
tariffs: list[Tariff] = []
for item in data:
tariffs.append(_tariff_from_item(item))
return tariffs
def save_tariffs(path: str | Path, tariffs: list[Tariff]) -> None:
payload = [t.to_dict() for t in tariffs]
text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
Path(path).write_text(text, encoding="utf-8")
def replace_shared_tariffs(shared: list[Tariff], new: list[Tariff]) -> None:
"""Обновить общий список in-place (bot + webapp + workers)."""
shared[:] = list(new)
def get_tariff(tariffs: list[Tariff], tariff_id: str) -> Tariff | None:
for t in tariffs:
if t.id == tariff_id:
return t
return None
def parse_tariff_payload(
body: dict,
*,
existing: Tariff | None = None,
default_squads: tuple[UUID, ...] = (),
) -> Tariff:
tid = str(body.get("id") or (existing.id if existing else "")).strip()
if not TARIFF_ID_RE.fullmatch(tid):
raise TariffError("ID: 2–40 символов (латиница, цифры, _)")
title = str(body.get("title") or (existing.title if existing else "")).strip()
if not title:
raise TariffError("Укажите название тарифа")
if len(title) > 80:
raise TariffError("Название слишком длинное")
try:
price = float(body.get("price") if "price" in body else (existing.price if existing else 0))
except (TypeError, ValueError) as exc:
raise TariffError("Некорректная цена") from exc
if price < 0:
raise TariffError("Цена не может быть отрицательной")
try:
days = int(body.get("days") if "days" in body else (existing.days if existing else 0))
except (TypeError, ValueError) as exc:
raise TariffError("Некорректное число дней") from exc
if days < 1 or days > 3650:
raise TariffError("Дни: от 1 до 3650")
try:
traffic_gb = float(
body.get("traffic_gb")
if "traffic_gb" in body
else (existing.traffic_gb if existing else 1000)
)
except (TypeError, ValueError) as exc:
raise TariffError("Некорректный трафик") from exc
if traffic_gb < 0:
raise TariffError("Трафик не может быть отрицательным")
try:
device_limit = int(
body.get("device_limit")
if "device_limit" in body
else (existing.device_limit if existing else 10)
)
except (TypeError, ValueError) as exc:
raise TariffError("Некорректный лимит устройств") from exc
if device_limit < 1 or device_limit > 100:
raise TariffError("Устройства: от 1 до 100")
if "squad_uuids" in body:
squads_raw = body.get("squad_uuids")
if isinstance(squads_raw, str):
parts = [p.strip() for p in squads_raw.replace(";", ",").split(",") if p.strip()]
elif isinstance(squads_raw, list):
parts = [str(x).strip() for x in squads_raw if str(x).strip()]
else:
parts = []
try:
squad_uuids = tuple(UUID(p) for p in parts)
except ValueError as exc:
raise TariffError("Некорректный UUID в squad_uuids") from exc
elif existing:
squad_uuids = existing.squad_uuids
else:
squad_uuids = default_squads
if not squad_uuids:
raise TariffError("Нужен хотя бы один squad UUID")
description = str(
body.get("description")
if "description" in body
else (existing.description if existing else "")
).strip()
if len(description) > 300:
raise TariffError("Описание слишком длинное")
return Tariff(
id=tid,
title=title,
price=price,
days=days,
traffic_gb=traffic_gb,
device_limit=device_limit,
squad_uuids=squad_uuids,
description=description,
)
def _tariff_from_item(item: dict) -> Tariff:
return Tariff(
id=str(item["id"]),
title=str(item["title"]),
price=float(item["price"]),
days=int(item["days"]),
traffic_gb=float(item.get("traffic_gb") or 0),
device_limit=int(item.get("device_limit") or 1),
squad_uuids=tuple(UUID(str(u)) for u in item.get("squad_uuids") or []),
description=str(item.get("description") or ""),
)
+193
View File
@@ -0,0 +1,193 @@
"""Валидация Telegram WebApp initData, Login Widget и OIDC id_token."""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import time
from dataclasses import dataclass
from typing import Any, Mapping
from urllib.parse import parse_qsl
import jwt
from jwt import PyJWKClient
logger = logging.getLogger(__name__)
_JWKS_URL = "https://oauth.telegram.org/.well-known/jwks.json"
_ISSUER = "https://oauth.telegram.org"
_jwks_client: PyJWKClient | None = None
@dataclass(frozen=True, slots=True)
class WebAppUser:
id: int
first_name: str
last_name: str | None = None
username: str | None = None
language_code: str | None = None
is_premium: bool = False
@property
def full_name(self) -> str:
if self.last_name:
return f"{self.first_name} {self.last_name}"
return self.first_name
class InitDataError(ValueError):
pass
def telegram_client_id_from_token(bot_token: str) -> str:
"""Числовой Client ID бота = часть BOT_TOKEN до ':'."""
raw = (bot_token or "").strip()
if ":" in raw:
return raw.split(":", 1)[0].strip()
return raw
def parse_and_validate_init_data(
init_data: str,
bot_token: str,
*,
max_age_sec: int = 86400,
) -> WebAppUser:
if not init_data or not init_data.strip():
raise InitDataError("empty initData")
pairs = dict(parse_qsl(init_data, keep_blank_values=True))
received_hash = pairs.pop("hash", None)
if not received_hash:
raise InitDataError("missing hash")
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(pairs.items()))
secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
calculated = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(calculated, received_hash):
raise InitDataError("invalid hash")
auth_date = int(pairs.get("auth_date") or 0)
if auth_date and abs(time.time() - auth_date) > max_age_sec:
raise InitDataError("initData expired")
user_raw = pairs.get("user")
if not user_raw:
raise InitDataError("missing user")
user = json.loads(user_raw)
return WebAppUser(
id=int(user["id"]),
first_name=str(user.get("first_name") or "User"),
last_name=user.get("last_name"),
username=user.get("username"),
language_code=user.get("language_code"),
is_premium=bool(user.get("is_premium")),
)
def parse_and_validate_login_widget(
data: Mapping[str, Any],
bot_token: str,
*,
max_age_sec: int = 86400,
) -> WebAppUser:
"""Проверка данных legacy Telegram Login Widget (HMAC)."""
payload = {str(k): str(v) for k, v in data.items() if v is not None and str(v) != ""}
received_hash = payload.pop("hash", None)
if not received_hash:
raise InitDataError("missing hash")
data_check_string = "\n".join(f"{k}={payload[k]}" for k in sorted(payload.keys()))
secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest()
calculated = hmac.new(
secret_key,
data_check_string.encode("utf-8"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(calculated, received_hash):
raise InitDataError("invalid hash")
auth_date = int(payload.get("auth_date") or 0)
if not auth_date or abs(time.time() - auth_date) > max_age_sec:
raise InitDataError("login data expired")
if "id" not in payload:
raise InitDataError("missing id")
return WebAppUser(
id=int(payload["id"]),
first_name=str(payload.get("first_name") or "User"),
last_name=payload.get("last_name"),
username=payload.get("username"),
)
def _get_jwks_client() -> PyJWKClient:
global _jwks_client
if _jwks_client is None:
_jwks_client = PyJWKClient(_JWKS_URL, cache_keys=True, lifespan=3600)
return _jwks_client
def parse_and_validate_id_token(
id_token: str,
client_id: str,
*,
nonce: str | None = None,
) -> WebAppUser:
"""Проверка OIDC id_token нового Telegram Login (JWKS)."""
token = (id_token or "").strip()
aud = str(client_id or "").strip()
if not token:
raise InitDataError("missing id_token")
if not aud:
raise InitDataError("telegram client_id not configured")
try:
signing_key = _get_jwks_client().get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256", "RS384", "RS512", "PS256", "ES256", "ES384", "EdDSA"],
audience=aud,
issuer=_ISSUER,
options={"require": ["exp", "iat", "iss", "aud"]},
)
except jwt.PyJWTError as exc:
logger.info("telegram id_token invalid: %s", exc)
raise InitDataError("invalid id_token") from exc
if nonce and payload.get("nonce") and str(payload.get("nonce")) != str(nonce):
raise InitDataError("nonce mismatch")
tid_raw = payload.get("id")
if tid_raw is None:
tid_raw = payload.get("sub")
try:
tid = int(tid_raw)
except (TypeError, ValueError) as exc:
raise InitDataError("missing user id in id_token") from exc
given = str(payload.get("given_name") or "").strip()
family = str(payload.get("family_name") or "").strip() or None
full = str(payload.get("name") or "").strip()
if given:
first_name = given
last_name = family
elif full:
parts = full.split(None, 1)
first_name = parts[0]
last_name = parts[1] if len(parts) > 1 else family
else:
first_name = "User"
last_name = family
username = payload.get("preferred_username") or payload.get("username")
return WebAppUser(
id=tid,
first_name=first_name,
last_name=last_name,
username=str(username) if username else None,
)
+231
View File
@@ -0,0 +1,231 @@
"""Автоответы ИИ по тикетам через OpenRouter."""
from __future__ import annotations
import html
import logging
import re
from typing import Any
from aiogram import Bot
from config import Settings
from services.db import Database
from services.faq import FAQ_ITEMS
from services.openrouter import (
AiDecision,
chat_completion,
load_openrouter_config,
parse_ai_decision,
)
from services.tickets import (
BODY_MAX,
clamp_body,
notify_user_ticket_reply,
)
logger = logging.getLogger(__name__)
_HTML_RE = re.compile(r"<[^>]+>")
def _strip_html(text: str) -> str:
t = _HTML_RE.sub("", text or "")
return (
t.replace("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", '"')
.strip()
)
def build_faq_context() -> str:
lines = ["FAQ сервиса:"]
for item in FAQ_ITEMS:
lines.append(f"Q: {_strip_html(item.question)}")
lines.append(f"A: {_strip_html(item.answer)}")
lines.append("")
return "\n".join(lines).strip()
def build_ticket_messages_for_ai(messages: list[dict[str, Any]]) -> list[dict[str, str]]:
out: list[dict[str, str]] = []
for msg in messages[-20:]:
role = str(msg.get("author_role") or "")
body = str(msg.get("body") or "").strip()
if not body:
continue
if role == "user":
out.append({"role": "user", "content": body})
else:
prefix = "ИИ" if role == "ai" else "Поддержка"
out.append({"role": "assistant", "content": f"[{prefix}] {body}"})
return out
async def notify_admins_ai_escalate(
bot: Bot,
settings: Settings,
*,
ticket_id: int,
telegram_id: int,
subject: str,
reason: str,
) -> None:
from keyboards import admin_ticket_notify_inline
text = (
f"<b>👤 Нужен человек · тикет #{ticket_id}</b>\n"
f"От: <code>{telegram_id}</code>\n"
f"Тема: <b>{html.escape(subject)}</b>\n\n"
f"ИИ эскалировал: {html.escape((reason or 'без причины')[:400])}"
)
kb = admin_ticket_notify_inline(ticket_id)
for admin_id in settings.admin_ids:
try:
await bot.send_message(admin_id, text, reply_markup=kb, parse_mode="HTML")
except Exception as exc: # noqa: BLE001
logger.warning("notify admin escalate %s: %s", admin_id, exc)
async def try_ai_ticket_reply(
*,
db: Database,
bot: Bot | None,
settings: Settings,
ticket_id: int,
notify_user: bool = True,
) -> dict[str, Any]:
"""
Пытается ответить на тикет через OpenRouter.
Возвращает dict: ok, action, detail, model?
"""
cfg = load_openrouter_config(db)
if not cfg.ready or not cfg.auto_reply:
return {"ok": False, "action": "skip", "detail": "ai disabled"}
ticket = db.get_ticket(ticket_id)
if not ticket:
return {"ok": False, "action": "skip", "detail": "ticket not found"}
if str(ticket.get("status")) == "closed":
return {"ok": False, "action": "skip", "detail": "closed"}
messages = db.list_ticket_messages(ticket_id)
if not messages:
return {"ok": False, "action": "skip", "detail": "no messages"}
last = messages[-1]
if str(last.get("author_role")) != "user":
return {"ok": False, "action": "skip", "detail": "last not user"}
subject = str(ticket.get("subject") or "")
system = cfg.system_prompt.strip()
faq = build_faq_context()
system_full = (
f"{system}\n\n{faq}\n\n"
f"Текущий тикет #{ticket_id}, тема: {subject}"
)
chat_messages = [
{"role": "system", "content": system_full},
*build_ticket_messages_for_ai(messages),
]
referer = (settings.webapp_url or "").rstrip("/") or "https://bot.example.com"
try:
raw, used_model = await chat_completion(
api_key=cfg.api_key,
model=cfg.model,
models=cfg.models_for_request(),
messages=chat_messages,
referer=referer,
)
decision = parse_ai_decision(raw, used_model=used_model)
except Exception as exc: # noqa: BLE001
logger.exception("OpenRouter ticket #%s failed", ticket_id)
if bot is not None:
await notify_admins_ai_escalate(
bot,
settings,
ticket_id=ticket_id,
telegram_id=int(ticket["telegram_id"]),
subject=subject,
reason=f"Ошибка ИИ: {exc}",
)
return {"ok": False, "action": "error", "detail": str(exc)}
return await _apply_decision(
db=db,
bot=bot,
settings=settings,
ticket=ticket,
decision=decision,
notify_user=notify_user,
)
async def _apply_decision(
*,
db: Database,
bot: Bot | None,
settings: Settings,
ticket: dict[str, Any],
decision: AiDecision,
notify_user: bool,
) -> dict[str, Any]:
ticket_id = int(ticket["id"])
subject = str(ticket.get("subject") or "")
telegram_id = int(ticket["telegram_id"])
if decision.action == "answer" and decision.reply.strip():
reply = clamp_body(decision.reply)
if len(reply) > BODY_MAX:
reply = reply[: BODY_MAX - 1] + ""
try:
db.add_ticket_message(
ticket_id,
author_role="ai",
author_id=None,
body=reply,
)
except ValueError as exc:
return {"ok": False, "action": "error", "detail": str(exc)}
if notify_user and bot is not None:
await notify_user_ticket_reply(
bot,
telegram_id=telegram_id,
ticket_id=ticket_id,
subject=subject,
body=reply,
is_ai=True,
)
logger.info(
"AI answered ticket #%s model=%s",
ticket_id,
decision.model or "?",
)
return {
"ok": True,
"action": "answer",
"detail": "ok",
"model": decision.model,
"reply": reply,
}
reason = decision.reason or "Нужен оператор"
if bot is not None:
await notify_admins_ai_escalate(
bot,
settings,
ticket_id=ticket_id,
telegram_id=telegram_id,
subject=subject,
reason=reason,
)
return {
"ok": True,
"action": "escalate",
"detail": reason,
"model": decision.model,
}
+227
View File
@@ -0,0 +1,227 @@
"""Тикеты поддержки: тексты, лимиты, уведомления."""
from __future__ import annotations
import html
import logging
from datetime import datetime
from typing import Any
from aiogram import Bot
from config import Settings
logger = logging.getLogger(__name__)
SUBJECT_MAX = 100
BODY_MAX = 2000
MAX_OPEN_PER_USER = 3
STATUS_LABELS: dict[str, str] = {
"open": "🟢 Открыт",
"answered": "💬 Есть ответ",
"closed": "🔒 Закрыт",
}
STATUS_LABELS_SHORT: dict[str, str] = {
"open": "Открыт",
"answered": "Ответ",
"closed": "Закрыт",
}
def status_label(status: str, *, short: bool = False) -> str:
key = (status or "").strip().lower()
table = STATUS_LABELS_SHORT if short else STATUS_LABELS
return table.get(key, status or "")
def clamp_subject(text: str) -> str:
return (text or "").strip()[:SUBJECT_MAX]
def clamp_body(text: str) -> str:
return (text or "").strip()[:BODY_MAX]
def _dt(value: Any) -> str:
if value is None:
return ""
if isinstance(value, datetime):
return value.isoformat()
return str(value)
def ticket_to_dict(row: dict[str, Any], *, include_user: bool = False) -> dict[str, Any]:
out: dict[str, Any] = {
"id": int(row["id"]),
"telegram_id": int(row["telegram_id"]),
"subject": str(row.get("subject") or ""),
"status": str(row.get("status") or "open"),
"status_label": status_label(str(row.get("status") or "open"), short=True),
"created_at": _dt(row.get("created_at")),
"updated_at": _dt(row.get("updated_at")),
"message_count": int(row.get("message_count") or 0),
}
if include_user:
name = row.get("full_name") or row.get("login")
username = row.get("username")
if name:
display = str(name)
elif username:
display = f"@{username}"
else:
display = f"ID {out['telegram_id']}"
out["user"] = {
"telegram_id": out["telegram_id"],
"display_name": display,
"username": username,
"login": row.get("login"),
}
return out
def message_to_dict(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": int(row["id"]),
"ticket_id": int(row["ticket_id"]),
"author_role": str(row.get("author_role") or ""),
"author_id": int(row["author_id"]) if row.get("author_id") is not None else None,
"body": str(row.get("body") or ""),
"created_at": _dt(row.get("created_at")),
}
def format_ticket_card(ticket: dict[str, Any], messages: list[dict[str, Any]]) -> str:
tid = int(ticket["id"])
subject = html.escape(str(ticket.get("subject") or ""))
status = status_label(str(ticket.get("status") or "open"))
lines = [
f"<b>🎫 Тикет #{tid}</b>",
f"Тема: <b>{subject}</b>",
f"Статус: {status}",
"",
]
for msg in messages[-12:]:
role = str(msg.get("author_role") or "")
if role == "user":
who = "Вы"
elif role == "ai":
who = "ИИ-поддержка"
else:
who = "Поддержка"
body = html.escape(str(msg.get("body") or ""))
lines.append(f"<b>{who}:</b>\n{body}")
lines.append("")
return "\n".join(lines).strip()
def format_admin_ticket_card(ticket: dict[str, Any], messages: list[dict[str, Any]]) -> str:
tid = int(ticket["id"])
subject = html.escape(str(ticket.get("subject") or ""))
status = status_label(str(ticket.get("status") or "open"))
uid = int(ticket["telegram_id"])
uname = ticket.get("username")
fname = ticket.get("full_name") or ticket.get("login") or ""
user_line = f"ID <code>{uid}</code>"
if uname:
user_line += f" · @{html.escape(str(uname))}"
if fname:
user_line += f" · {html.escape(str(fname))}"
lines = [
f"<b>🎫 Тикет #{tid}</b>",
f"Тема: <b>{subject}</b>",
f"Статус: {status}",
f"Пользователь: {user_line}",
"",
]
for msg in messages[-15:]:
role = str(msg.get("author_role") or "")
if role == "user":
who = "Пользователь"
elif role == "ai":
who = "ИИ"
else:
who = "Админ"
body = html.escape(str(msg.get("body") or ""))
lines.append(f"<b>{who}:</b>\n{body}")
lines.append("")
return "\n".join(lines).strip()
async def notify_admins_new_ticket(
bot: Bot,
settings: Settings,
*,
ticket_id: int,
telegram_id: int,
subject: str,
body: str,
ai_handled: bool = False,
) -> None:
prefix = "🤖 Новый тикет (ИИ ответил)" if ai_handled else "🆕 Новый тикет"
text = (
f"<b>{prefix} #{ticket_id}</b>\n"
f"От: <code>{telegram_id}</code>\n"
f"Тема: <b>{html.escape(subject)}</b>\n\n"
f"{html.escape(body[:500])}"
)
from keyboards import admin_ticket_notify_inline
kb = admin_ticket_notify_inline(ticket_id)
for admin_id in settings.admin_ids:
try:
await bot.send_message(admin_id, text, reply_markup=kb, parse_mode="HTML")
except Exception as exc: # noqa: BLE001
logger.warning("notify admin %s ticket: %s", admin_id, exc)
async def notify_admins_ticket_reply(
bot: Bot,
settings: Settings,
*,
ticket_id: int,
telegram_id: int,
body: str,
) -> None:
text = (
f"<b>💬 Ответ в тикете #{ticket_id}</b>\n"
f"От: <code>{telegram_id}</code>\n\n"
f"{html.escape(body[:500])}"
)
from keyboards import admin_ticket_notify_inline
kb = admin_ticket_notify_inline(ticket_id)
for admin_id in settings.admin_ids:
try:
await bot.send_message(admin_id, text, reply_markup=kb, parse_mode="HTML")
except Exception as exc: # noqa: BLE001
logger.warning("notify admin %s reply: %s", admin_id, exc)
async def notify_user_ticket_reply(
bot: Bot,
*,
telegram_id: int,
ticket_id: int,
subject: str,
body: str,
is_ai: bool = False,
) -> None:
from keyboards import ticket_notify_inline
title = "🤖 ИИ-поддержка" if is_ai else "💬 Ответ поддержки"
text = (
f"<b>{title} · тикет #{ticket_id}</b>\n"
f"Тема: <b>{html.escape(subject)}</b>\n\n"
f"{html.escape(body[:800])}"
)
try:
await bot.send_message(
telegram_id,
text,
reply_markup=ticket_notify_inline(ticket_id),
parse_mode="HTML",
)
except Exception as exc: # noqa: BLE001
logger.warning("notify user %s ticket reply: %s", telegram_id, exc)
+65
View File
@@ -0,0 +1,65 @@
"""Активация кода пополнения."""
from __future__ import annotations
from dataclasses import dataclass
from services.db import Database
from services.topup_codes import is_valid_code_format, normalize_code
class TopupCodeError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class TopupCodeRedeemResult:
ok: bool
message: str
amount: float = 0.0
balance: float = 0.0
already: bool = False
def redeem_topup_code(
db: Database,
*,
telegram_id: int,
raw_code: str,
) -> TopupCodeRedeemResult:
code = normalize_code(raw_code)
if not is_valid_code_format(code):
raise TopupCodeError("Некорректный формат кода (ожидается XXXX-XXXX-XXXX-XXXX)")
db.ensure_user(telegram_id)
try:
amount, already = db.redeem_topup_code(code=code, telegram_id=telegram_id)
except ValueError as exc:
raise TopupCodeError(str(exc)) from exc
if already:
balance = db.get_balance(telegram_id)
return TopupCodeRedeemResult(
ok=True,
already=True,
message="Этот код уже был использован.",
amount=amount,
balance=balance,
)
balance = db.add_balance(
telegram_id,
amount,
kind="code_topup",
meta=f"code:{code}",
)
text = (
f"✅ <b>Баланс пополнен по коду</b>\n\n"
f"+{amount:.0f}\n"
f"Доступно: <b>{balance:.0f} ₽</b>"
)
return TopupCodeRedeemResult(
ok=True,
message=text,
amount=amount,
balance=balance,
)
+31
View File
@@ -0,0 +1,31 @@
"""Коды пополнения баланса."""
from __future__ import annotations
import re
import secrets
import string
# Без похожих символов O/0, I/1, L
_CODE_ALPHABET = "".join(
c for c in (string.ascii_uppercase + string.digits) if c not in "O0IL1"
)
_CODE_RE = re.compile(r"^[A-Z0-9]{4}(?:-[A-Z0-9]{4}){3}$")
def normalize_code(raw: str) -> str:
"""XXXX-XXXX-XXXX-XXXX из произвольного ввода."""
cleaned = "".join(ch for ch in (raw or "").upper() if ch.isalnum())
if len(cleaned) != 16:
return cleaned
return "-".join(cleaned[i : i + 4] for i in range(0, 16, 4))
def is_valid_code_format(code: str) -> bool:
norm = normalize_code(code)
return bool(_CODE_RE.fullmatch(norm))
def generate_code() -> str:
parts = ["".join(secrets.choice(_CODE_ALPHABET) for _ in range(4)) for _ in range(4)]
return "-".join(parts)
+134
View File
@@ -0,0 +1,134 @@
"""Лимиты и пакеты трафика."""
from __future__ import annotations
from dataclasses import dataclass
# База на подписку + докупка
BASE_TRAFFIC_GB = 1000.0
PACK_GB = 500.0
PACK_PRICE_RUB = 50.0
GB = 1024 * 1024 * 1024
def gb_to_bytes(gb: float) -> float:
return float(gb) * GB
def bytes_to_gb(value: float | int | None) -> float:
if not value:
return 0.0
return float(value) / GB
def format_gb(gb: float, *, digits: int = 1) -> str:
if gb >= 100 and abs(gb - round(gb)) < 0.05:
return f"{int(round(gb))} ГБ"
if digits <= 0:
return f"{int(round(gb))} ГБ"
text = f"{gb:.{digits}f}".rstrip("0").rstrip(".")
return f"{text} ГБ"
def effective_limit_bytes(limit_bytes: float | int | None) -> float:
"""0 / None в панели = безлимит; для нас база = 1000 ГБ."""
raw = float(limit_bytes or 0)
if raw <= 0:
return gb_to_bytes(BASE_TRAFFIC_GB)
return raw
def ensure_base_limit_bytes(current_limit_bytes: float | int | None) -> float:
"""Не меньше базовых 1000 ГБ; безлимит (0) → база."""
raw = float(current_limit_bytes or 0)
base = gb_to_bytes(BASE_TRAFFIC_GB)
if raw <= 0:
return base
return max(raw, base)
@dataclass(frozen=True, slots=True)
class TrafficPack:
packs: int
gb: float
price: float
@property
def id(self) -> str:
return f"pack_{self.packs}"
@property
def title(self) -> str:
return f"+{format_gb(self.gb, digits=0)}"
def format_price(self) -> str:
if self.price == int(self.price):
return f"{int(self.price)} ₽/мес"
return f"{self.price:.2f} ₽/мес"
# Готовые пакеты: 1 / 2 / 4 / 10 × 500 ГБ
TRAFFIC_PACKS: tuple[TrafficPack, ...] = tuple(
TrafficPack(
packs=n,
gb=PACK_GB * n,
price=PACK_PRICE_RUB * n,
)
for n in (1, 2, 4, 10)
)
def get_traffic_pack(pack_id: str) -> TrafficPack | None:
for p in TRAFFIC_PACKS:
if p.id == pack_id or str(p.packs) == pack_id:
return p
return None
@dataclass(frozen=True, slots=True)
class TrafficUsage:
used_bytes: float
limit_bytes: float
@property
def used_gb(self) -> float:
return bytes_to_gb(self.used_bytes)
@property
def limit_gb(self) -> float:
return bytes_to_gb(self.limit_bytes)
@property
def left_gb(self) -> float:
return max(0.0, self.limit_gb - self.used_gb)
@property
def percent(self) -> float:
if self.limit_bytes <= 0:
return 0.0
return min(100.0, max(0.0, 100.0 * self.used_bytes / self.limit_bytes))
def format_line(self) -> str:
return (
f"📦 Трафик: <b>{format_gb(self.used_gb)} / {format_gb(self.limit_gb)}</b> "
f"({self.percent:.0f}%)\n"
f"Осталось: <b>{format_gb(self.left_gb)}</b>"
)
def as_dict(self) -> dict:
return {
"used_gb": round(self.used_gb, 2),
"limit_gb": round(self.limit_gb, 2),
"left_gb": round(self.left_gb, 2),
"percent": round(self.percent, 1),
"used_label": format_gb(self.used_gb),
"limit_label": format_gb(self.limit_gb),
"left_label": format_gb(self.left_gb),
}
def traffic_from_panel_user(user) -> TrafficUsage:
used = float(getattr(user, "used_traffic_bytes", 0) or 0)
limit = effective_limit_bytes(getattr(user, "traffic_limit_bytes", 0))
return TrafficUsage(used_bytes=used, limit_bytes=limit)
+49
View File
@@ -0,0 +1,49 @@
"""Человекочитаемые подписи к операциям баланса."""
from __future__ import annotations
_KIND_LABELS: dict[str, str] = {
"topup": "Пополнение",
"admin_credit": "Зачисление администратором",
"topup_code": "Код пополнения",
"stars_topup": "Telegram Stars",
"crypto_topup": "Крипто-пополнение",
"lava_topup": "Lava",
"anypay_topup": "AnyPay",
"purchase": "Покупка тарифа",
"renewal": "Продление подписки",
"traffic": "Докупка трафика",
"devices": "Доп. устройства",
"extras_renewal": "Продление доп. опций",
"wg_config": "WireGuard конфиг",
"awg_config": "AmneziaWG конфиг",
"refund": "Возврат",
}
def transaction_label(kind: str, meta: str | None = None) -> str:
k = (kind or "").strip()
m = (meta or "").strip()
if k == "admin_credit":
if " | admin:" in m:
m = m.split(" | admin:", 1)[0].strip()
return m or _KIND_LABELS["admin_credit"]
if k == "topup_code" and m:
return f"Код пополнения · {m}"
if m and k in ("purchase", "renewal", "traffic", "devices", "wg_config", "awg_config", "refund"):
return f"{_KIND_LABELS.get(k, k)} · {m}"
return _KIND_LABELS.get(k, k or "Операция")
def transaction_row_dict(amount: float, kind: str, meta: str | None, created_at) -> dict:
sign = "+" if amount > 0 else ""
amt = float(amount)
label = transaction_label(kind, meta)
return {
"amount": amt,
"kind": kind,
"meta": meta,
"label": label,
"amount_label": f"{sign}{amt:.0f}",
"created_at": created_at.isoformat() if hasattr(created_at, "isoformat") else str(created_at),
}
+99
View File
@@ -0,0 +1,99 @@
"""Пробный период подписки."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from remnawave import RemnawaveSDK
from services.db import AuthError, Database
from services.purchase import provision_subscription
from services.tariffs import Tariff, get_tariff
logger = logging.getLogger(__name__)
DEFAULT_TRIAL_DAYS = 3
@dataclass(frozen=True, slots=True)
class TrialResult:
ok: bool
message: str
expire_at: datetime | None = None
def trial_days_setting(db: Database) -> int:
raw = (db.get_setting("trial_days") or str(DEFAULT_TRIAL_DAYS)).strip()
try:
days = int(raw)
except ValueError:
days = DEFAULT_TRIAL_DAYS
return max(0, min(days, 30))
def trial_enabled(db: Database) -> bool:
return trial_days_setting(db) > 0
async def grant_trial(
*,
db: Database,
sdk: RemnawaveSDK,
telegram_id: int,
tariffs: list[Tariff],
) -> TrialResult:
days = trial_days_setting(db)
if days <= 0:
return TrialResult(ok=False, message="Пробный период выключен")
user = db.ensure_user(telegram_id)
if user.banned:
return TrialResult(ok=False, message="Аккаунт заблокирован")
if user.trial_used_at:
return TrialResult(ok=False, message="Пробный период уже использован")
tariff_id = (db.get_setting("trial_tariff_id") or "").strip()
tariff = get_tariff(tariffs, tariff_id) if tariff_id else None
if tariff is None and tariffs:
tariff = min(tariffs, key=lambda t: t.price)
if tariff is None:
return TrialResult(ok=False, message="Нет тарифа для пробного периода")
trial_tariff = Tariff(
id=tariff.id,
title=f"Trial · {tariff.title}",
price=0.0,
days=days,
traffic_gb=tariff.traffic_gb,
device_limit=tariff.device_limit,
squad_uuids=tariff.squad_uuids,
description="trial",
)
try:
rw_uuid, sub_url, expire_at = await provision_subscription(
sdk,
telegram_id=telegram_id,
tariff=trial_tariff,
login=user.login,
extra_devices=user.extra_devices,
)
except Exception as exc: # noqa: BLE001
logger.exception("trial provision failed")
return TrialResult(ok=False, message=f"Не удалось выдать пробный период: {exc}")
db.mark_trial_used(telegram_id)
db.record_purchase(
telegram_id,
tariff_id=f"trial:{tariff.id}",
price=0.0,
remnawave_uuid=rw_uuid,
subscription_url=sub_url,
)
return TrialResult(
ok=True,
message=f"Пробный период на {days} дн. активен до {expire_at.strftime('%d.%m.%Y %H:%M UTC')}",
expire_at=expire_at,
)
+63
View File
@@ -0,0 +1,63 @@
"""Фоновое отключение WG/AWG конфигов при истекшей подписке Remnawave."""
from __future__ import annotations
import asyncio
import logging
from remnawave import RemnawaveSDK
from services.awg_configs import enforce_awg_configs_subscription
from services.db import Database
from services.xui_wg import enforce_wg_configs_subscription
logger = logging.getLogger(__name__)
async def run_vpn_configs_expiry_cycle(
db: Database,
sdk: RemnawaveSDK,
*,
limit: int = 500,
) -> int:
ids = db.list_telegram_ids_with_enabled_vpn_configs(limit=limit)
touched = 0
for tid in ids:
try:
user = db.get_user(tid)
login = user.login if user else None
wg = await enforce_wg_configs_subscription(
db=db, sdk=sdk, telegram_id=tid, login=login
)
awg = await enforce_awg_configs_subscription(
db=db, sdk=sdk, telegram_id=tid, login=login
)
if (wg.get("disabled") or 0) + (awg.get("disabled") or 0) > 0:
touched += 1
logger.info(
"vpn configs auto-disabled tg=%s wg=%s awg=%s",
tid,
wg.get("disabled"),
awg.get("disabled"),
)
except Exception: # noqa: BLE001
logger.exception("vpn configs expiry enforce failed tg=%s", tid)
return touched
async def vpn_configs_expiry_worker(
db: Database,
sdk: RemnawaveSDK,
*,
interval_sec: int = 120,
) -> None:
"""Каждые ~2 минуты проверяет включённые конфиги и гасит их без подписки."""
await asyncio.sleep(45)
while True:
try:
n = await run_vpn_configs_expiry_cycle(db, sdk)
if n:
logger.info("vpn configs expiry cycle: disabled for %s users", n)
except Exception: # noqa: BLE001
logger.exception("vpn configs expiry cycle error")
await asyncio.sleep(interval_sec)
+264
View File
@@ -0,0 +1,264 @@
"""Пополнение баланса с сайта (Lava / AnyPay / Heleket / Crypto Pay)."""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any
from services.anypay import Anypay, AnypayError
from services.cryptopay import CryptoPay, CryptoPayError
from services.db import Database
from services.heleket import Heleket, HeleketError
from services.lava import Lava, LavaError
TOPUP_MIN_RUB = 10.0
TOPUP_MAX_RUB = 3000.0
TOPUP_PRESETS_RUB: tuple[int, ...] = (50, 100, 200, 500, 1000, 2000, 3000)
PROVIDERS = ("lava", "anypay", "heleket", "cryptobot")
class TopupError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class TopupInvoice:
provider: str
amount_rub: float
pay_url: str
invoice_id: int
order_id: str | None = None
title: str = ""
def parse_topup_amount(raw: Any) -> float:
try:
amount = float(raw)
except (TypeError, ValueError) as exc:
raise TopupError("Укажите сумму числом") from exc
amount = round(amount, 2)
if amount < TOPUP_MIN_RUB:
raise TopupError(f"Минимум {int(TOPUP_MIN_RUB)} ₽ за одну оплату")
if amount > TOPUP_MAX_RUB:
raise TopupError(f"Максимум {int(TOPUP_MAX_RUB)} ₽ за одну оплату")
return amount
def normalize_provider(raw: Any) -> str:
provider = str(raw or "").strip().lower()
if provider in {"crypto", "cryptopay", "crypto_bot"}:
provider = "cryptobot"
if provider not in PROVIDERS:
raise TopupError("Неизвестный способ оплаты")
return provider
def _safe_order_id(raw: str) -> str:
cleaned = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in raw)
return cleaned[:128]
def available_providers(
*,
cryptopay: CryptoPay,
heleket: Heleket,
lava: Lava,
anypay: Anypay | None = None,
) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
if lava.enabled:
items.append(
{
"id": "lava",
"title": "Карта / СБП · Lava",
"hint": "Lava.ru · зачисление автоматически",
}
)
if anypay and anypay.enabled:
items.append(
{
"id": "anypay",
"title": "AnyPay",
"hint": "Карта, СБП, крипта и др. · anypay.io",
}
)
if heleket.enabled:
items.append(
{
"id": "heleket",
"title": "Крипта · Heleket",
"hint": "USDT и др. · зачисление по webhook",
}
)
if cryptopay.enabled:
items.append(
{
"id": "cryptobot",
"title": "Крипта · Crypto Bot",
"hint": "Оплата в @CryptoBot",
}
)
return items
def topup_methods_payload(
*,
cryptopay: CryptoPay,
heleket: Heleket,
lava: Lava,
anypay: Anypay | None = None,
) -> dict[str, Any]:
return {
"min_amount": int(TOPUP_MIN_RUB),
"max_amount": int(TOPUP_MAX_RUB),
"presets": list(TOPUP_PRESETS_RUB),
"providers": available_providers(
cryptopay=cryptopay, heleket=heleket, lava=lava, anypay=anypay
),
}
async def create_web_topup_invoice(
*,
db: Database,
telegram_id: int,
amount_rub: float,
provider: str,
cryptopay: CryptoPay,
heleket: Heleket,
lava: Lava,
anypay: Anypay | None = None,
) -> TopupInvoice:
amount = parse_topup_amount(amount_rub)
provider = normalize_provider(provider)
stamp = int(time.time())
order_id = _safe_order_id(f"web_topup_{telegram_id}_{int(amount)}_{stamp}")
if provider == "lava":
if not lava.enabled:
raise TopupError("Lava не настроена")
try:
inv = await lava.create_invoice(
amount_rub=amount,
order_id=order_id,
comment=f"Web topup {int(amount)} RUB uid={telegram_id}",
custom_fields=str(telegram_id),
)
except LavaError as exc:
raise TopupError(str(exc)) from exc
local_id = db.next_local_invoice_id()
db.create_invoice_record(
telegram_id=telegram_id,
invoice_id=local_id,
kind="topup",
amount_rub=amount,
pay_url=inv.pay_url,
payload=None,
provider="lava",
order_id=order_id,
external_uuid=inv.invoice_id,
)
return TopupInvoice(
provider="lava",
amount_rub=amount,
pay_url=inv.pay_url,
invoice_id=local_id,
order_id=order_id,
title="Карта / СБП · Lava",
)
if provider == "anypay":
if not anypay or not anypay.enabled:
raise TopupError("AnyPay не настроен")
local_id = db.next_local_invoice_id()
pay_id = str(local_id)
try:
inv = await anypay.create_invoice(
pay_id=pay_id,
amount_rub=amount,
desc=f"Web topup {int(amount)} RUB uid={telegram_id}",
)
except AnypayError as exc:
raise TopupError(str(exc)) from exc
db.create_invoice_record(
telegram_id=telegram_id,
invoice_id=local_id,
kind="topup",
amount_rub=amount,
pay_url=inv.pay_url,
payload=pay_id,
provider="anypay",
order_id=pay_id,
external_uuid=inv.transaction_id,
)
return TopupInvoice(
provider="anypay",
amount_rub=amount,
pay_url=inv.pay_url,
invoice_id=local_id,
order_id=pay_id,
title="AnyPay",
)
if provider == "heleket":
if not heleket.enabled:
raise TopupError("Heleket не настроен")
try:
inv = await heleket.create_invoice(
amount_rub=amount,
order_id=order_id,
description=f"Web topup {int(amount)} RUB uid={telegram_id}",
)
except HeleketError as exc:
raise TopupError(str(exc)) from exc
local_id = db.next_local_invoice_id()
db.create_invoice_record(
telegram_id=telegram_id,
invoice_id=local_id,
kind="topup",
amount_rub=amount,
pay_url=inv.pay_url,
payload=order_id,
provider="heleket",
order_id=order_id,
external_uuid=inv.uuid,
)
return TopupInvoice(
provider="heleket",
amount_rub=amount,
pay_url=inv.pay_url,
invoice_id=local_id,
order_id=order_id,
title="Крипта · Heleket",
)
if not cryptopay.enabled:
raise TopupError("Crypto Bot не настроен")
payload = f"topup:{telegram_id}:{int(amount)}:web"
try:
invoice = await cryptopay.create_fiat_invoice(
amount_rub=amount,
description=f"Пополнение баланса на {int(amount)}",
payload=payload,
)
except CryptoPayError as exc:
raise TopupError(str(exc)) from exc
db.create_invoice_record(
telegram_id=telegram_id,
invoice_id=invoice.invoice_id,
kind="topup",
amount_rub=amount,
pay_url=invoice.pay_url,
payload=payload,
provider="cryptobot",
)
return TopupInvoice(
provider="cryptobot",
amount_rub=amount,
pay_url=invoice.pay_url or invoice.bot_invoice_url,
invoice_id=invoice.invoice_id,
order_id=None,
title="Крипта · Crypto Bot",
)
+833
View File
@@ -0,0 +1,833 @@
"""Доп. API: рефералка, trial, промо, пароль, сессии, HWID, legal, бэкапы."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from uuid import UUID
from aiohttp import web
from remnawave.models import HWIDDeleteRequest
from config import Settings, ROOT_DIR
from services.db import AuthError, BotUser, Database
from services.legal_pages import (
html_aup,
html_contacts,
html_cookies,
html_delivery,
html_help,
html_offer,
html_privacy,
html_refund,
html_site_rules,
html_status,
html_support,
html_terms,
html_vpn_usage,
)
from services.mailer import render_password_reset_email, send_email, smtp_configured
from services.pg_backup import (
delete_backup,
list_backups,
resolve_backup_path,
restore_from_backup_name,
restore_from_bytes,
run_pg_dump_backup,
save_uploaded_backup,
)
from services.rate_limit import allow, client_ip
from services.remnawave_users import find_panel_user
from services.trial import grant_trial, trial_days_setting, trial_enabled
logger = logging.getLogger(__name__)
def _require_user(request: web.Request) -> BotUser:
from webapp_server import _require_user as req
return req(request)
def _user_payload(user: BotUser) -> dict:
from webapp_server import _user_payload as payload
return payload(user)
def _session_token(request: web.Request) -> str | None:
from webapp_server import _session_token as tok
return tok(request)
def _set_session_cookie(resp, request, token: str) -> None:
from webapp_server import _set_session_cookie as setc
setc(resp, request, token)
def register_growth_routes(app: web.Application) -> None:
app.router.add_get("/api/referral", handle_referral_get)
app.router.add_post("/api/trial", handle_trial)
app.router.add_get("/api/account/notifications", handle_notify_get)
app.router.add_post("/api/account/notifications", handle_notify_set)
app.router.add_get("/api/account/sessions", handle_sessions_list)
app.router.add_post("/api/account/sessions/logout-all", handle_sessions_logout_all)
app.router.add_post("/api/auth/password/forgot", handle_password_forgot)
app.router.add_post("/api/auth/password/reset", handle_password_reset)
app.router.add_get("/api/devices/hwid", handle_hwid_list)
app.router.add_post("/api/devices/hwid/delete", handle_hwid_delete)
app.router.add_get("/terms", handle_terms_page)
app.router.add_get("/offer", handle_offer_page)
app.router.add_get("/privacy", handle_privacy_page)
app.router.add_get("/aup", handle_aup_page)
app.router.add_get("/vpn-usage", handle_vpn_usage_page)
app.router.add_get("/vpn_usage", handle_vpn_usage_page)
app.router.add_get("/rules", handle_rules_page)
app.router.add_get("/refund", handle_refund_page)
app.router.add_get("/delivery", handle_delivery_page)
app.router.add_get("/contacts", handle_contacts_page)
app.router.add_get("/help", handle_help_page)
app.router.add_get("/support", handle_support_page)
app.router.add_get("/cookies", handle_cookies_page)
app.router.add_get("/status", handle_status_page)
app.router.add_get("/api/admin/promos", handle_admin_promos_list)
app.router.add_post("/api/admin/promos", handle_admin_promos_create)
app.router.add_post("/api/admin/promos/{promo_id}/toggle", handle_admin_promos_toggle)
app.router.add_post("/api/admin/users/ban", handle_admin_user_ban)
app.router.add_post("/api/admin/users/delete", handle_admin_user_delete)
app.router.add_get("/api/admin/payments/journal", handle_admin_payment_journal)
app.router.add_get("/api/admin/backups", handle_admin_backups_list)
app.router.add_post("/api/admin/backups", handle_admin_backups_run)
app.router.add_get("/api/admin/backups/{name}/download", handle_admin_backups_download)
app.router.add_delete("/api/admin/backups/{name}", handle_admin_backups_delete)
app.router.add_post("/api/admin/backups/delete", handle_admin_backups_delete_post)
app.router.add_post("/api/admin/backups/restore", handle_admin_backups_restore)
app.router.add_post("/api/admin/backups/upload", handle_admin_backups_upload)
app.router.add_get("/api/admin/growth-settings", handle_admin_growth_get)
app.router.add_post("/api/admin/growth-settings", handle_admin_growth_set)
async def handle_referral_get(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
db: Database = request.app["db"]
try:
user = _require_user(request)
except web.HTTPUnauthorized:
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
code = db.ensure_referral_code(user.telegram_id)
bot = str(request.app.get("bot_username") or "").lstrip("@")
link = f"https://t.me/{bot}?start=ref_{code}" if bot else ""
web_link = f"{settings.webapp_url.rstrip('/')}/?ref={code}" if settings.webapp_url else ""
return web.json_response(
{
"ok": True,
"code": code,
"telegram_link": link,
"web_link": web_link,
"bonus_referrer": float(db.get_setting("referral_bonus_referrer") or "50"),
"bonus_referee": float(db.get_setting("referral_bonus_referee") or "50"),
}
)
async def handle_trial(request: web.Request) -> web.Response:
db: Database = request.app["db"]
sdk = request.app["sdk"]
try:
user = _require_user(request)
except web.HTTPUnauthorized:
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
if not allow(request, "trial", limit=5, window_sec=3600):
return web.json_response({"ok": False, "error": "Слишком часто"}, status=429)
result = await grant_trial(
db=db, sdk=sdk, telegram_id=user.telegram_id, tariffs=request.app["tariffs"]
)
status = 200 if result.ok else 400
return web.json_response(
{
"ok": result.ok,
"error": None if result.ok else result.message,
"message": result.message,
"expire_at": result.expire_at.isoformat() if result.expire_at else None,
"trial_enabled": trial_enabled(db),
"trial_days": trial_days_setting(db),
},
status=status,
)
async def handle_notify_get(request: web.Request) -> web.Response:
try:
user = _require_user(request)
except web.HTTPUnauthorized:
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
return web.json_response(
{
"ok": True,
"notify_telegram": bool(user.notify_telegram),
"notify_email": bool(user.notify_email),
}
)
async def handle_notify_set(request: web.Request) -> web.Response:
db: Database = request.app["db"]
try:
user = _require_user(request)
except web.HTTPUnauthorized:
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
updated = db.set_notify_prefs(
user.telegram_id,
notify_telegram=body.get("notify_telegram"),
notify_email=body.get("notify_email"),
)
return web.json_response(
{
"ok": True,
"notify_telegram": updated.notify_telegram,
"notify_email": updated.notify_email,
"user": _user_payload(updated),
}
)
async def handle_sessions_list(request: web.Request) -> web.Response:
db: Database = request.app["db"]
try:
user = _require_user(request)
except web.HTTPUnauthorized:
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
current = _session_token(request)
items = []
for s in db.list_sessions(user.telegram_id):
items.append(
{
"token_hint": (s["token"][:6] + "") if s.get("token") else "",
"current": s.get("token") == current,
"created_at": s["created_at"].isoformat()
if hasattr(s["created_at"], "isoformat")
else str(s["created_at"]),
"expires_at": s["expires_at"].isoformat()
if hasattr(s["expires_at"], "isoformat")
else str(s["expires_at"]),
"user_agent": s.get("user_agent"),
"ip": s.get("ip"),
}
)
return web.json_response({"ok": True, "sessions": items})
async def handle_sessions_logout_all(request: web.Request) -> web.Response:
db: Database = request.app["db"]
try:
user = _require_user(request)
except web.HTTPUnauthorized:
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
keep = _session_token(request)
deleted = db.clear_all_sessions(user.telegram_id, keep_token=keep)
return web.json_response({"ok": True, "deleted": deleted})
async def handle_password_forgot(request: web.Request) -> web.Response:
import os
from dotenv import load_dotenv
from config import get_settings
# Подтянуть .env (SMTP из админки) и обновить runtime settings
env_file = (os.getenv("ENV_FILE") or "").strip() or None
load_dotenv(env_file, override=True)
settings = get_settings(require_bot_token=False)
request.app["settings"] = settings
db: Database = request.app["db"]
if not allow(request, "pwd_forgot", limit=5, window_sec=600):
return web.json_response({"ok": False, "error": "Слишком часто"}, status=429)
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
email = str(body.get("email") or "").strip()
if not email or "@" not in email:
return web.json_response({"ok": False, "error": "Укажите корректный email"}, status=400)
if not smtp_configured(settings):
return web.json_response(
{
"ok": False,
"error": "Сброс пароля недоступен: SMTP не настроен (Админка → SMTP)",
},
status=503,
)
if not (settings.webapp_url or "").strip():
return web.json_response(
{
"ok": False,
"error": "Сброс пароля недоступен: не задан WEBAPP_URL",
},
status=503,
)
# Не раскрываем, есть ли email в базе
user = db.get_user_by_email(email)
if user and user.email:
token = db.create_password_reset_token(user.telegram_id)
link = f"{settings.webapp_url.rstrip('/')}/?reset={token}"
try:
await send_email(
settings,
to=user.email,
subject="Сброс пароля — VPN Service",
html=render_password_reset_email(
reset_url=link,
webapp_url=settings.webapp_url or "",
),
)
except Exception as exc: # noqa: BLE001
logger.exception("password reset mail failed")
return web.json_response(
{"ok": False, "error": f"Не удалось отправить письмо: {exc}"},
status=502,
)
return web.json_response(
{
"ok": True,
"message": "Если email найден — отправили ссылку для сброса. Проверьте почту и «Спам».",
}
)
async def handle_password_reset(request: web.Request) -> web.Response:
db: Database = request.app["db"]
if not allow(request, "pwd_reset", limit=10, window_sec=600):
return web.json_response({"ok": False, "error": "Слишком часто"}, status=429)
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
token = str(body.get("token") or "")
password = str(body.get("password") or "")
try:
tid = db.consume_password_reset_token(token)
db.set_password_hash(tid, password)
db.clear_all_sessions(tid)
session = db.create_session(
tid, user_agent=request.headers.get("User-Agent"), ip=client_ip(request)
)
except AuthError as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=400)
user = db.get_user(tid)
assert user is not None
resp = web.json_response(
{"ok": True, "token": session, "user": _user_payload(user), "balance": user.balance}
)
_set_session_cookie(resp, request, session)
return resp
async def handle_hwid_list(request: web.Request) -> web.Response:
db: Database = request.app["db"]
sdk = request.app["sdk"]
try:
user = _require_user(request)
except web.HTTPUnauthorized:
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
panel = await find_panel_user(sdk, user.telegram_id, login=user.login)
if panel is None:
return web.json_response({"ok": True, "devices": [], "total": 0})
try:
resp = await sdk.hwid.get_hwid_user(str(panel.uuid))
devices = []
for d in getattr(resp, "devices", None) or []:
devices.append(
{
"hwid": getattr(d, "hwid", ""),
"platform": getattr(d, "platform", None),
"os_version": getattr(d, "os_version", None),
"device_model": getattr(d, "device_model", None),
"user_agent": getattr(d, "user_agent", None),
"created_at": getattr(d, "created_at", None).isoformat()
if getattr(d, "created_at", None)
else None,
}
)
return web.json_response(
{
"ok": True,
"total": int(getattr(resp, "total", None) or len(devices)),
"devices": devices,
}
)
except Exception as exc: # noqa: BLE001
logger.exception("hwid list failed")
return web.json_response({"ok": False, "error": str(exc)}, status=500)
async def handle_hwid_delete(request: web.Request) -> web.Response:
db: Database = request.app["db"]
sdk = request.app["sdk"]
try:
user = _require_user(request)
except web.HTTPUnauthorized:
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
hwid = str(body.get("hwid") or "").strip()
if not hwid:
return web.json_response({"ok": False, "error": "hwid required"}, status=400)
panel = await find_panel_user(sdk, user.telegram_id, login=user.login)
if panel is None:
return web.json_response({"ok": False, "error": "Нет подписки"}, status=400)
try:
await sdk.hwid.delete_hwid_to_user(
HWIDDeleteRequest(user_uuid=UUID(str(panel.uuid)), hwid=hwid)
)
from services.remnawave_users import sync_device_policy
from services.extras_billing import effective_extra_devices
fresh = await find_panel_user(sdk, user.telegram_id, login=user.login)
if fresh is not None:
await sync_device_policy(
sdk,
fresh,
db=db,
telegram_id=user.telegram_id,
extra_paid=effective_extra_devices(user),
device_blocked=user.device_blocked,
)
except Exception as exc: # noqa: BLE001
logger.exception("hwid delete failed")
return web.json_response({"ok": False, "error": str(exc)}, status=500)
return web.json_response({"ok": True})
async def handle_terms_page(_: web.Request) -> web.Response:
return web.Response(text=html_terms(), content_type="text/html", charset="utf-8")
async def handle_offer_page(_: web.Request) -> web.Response:
return web.Response(text=html_offer(), content_type="text/html", charset="utf-8")
async def handle_privacy_page(_: web.Request) -> web.Response:
return web.Response(text=html_privacy(), content_type="text/html", charset="utf-8")
async def handle_aup_page(_: web.Request) -> web.Response:
return web.Response(text=html_aup(), content_type="text/html", charset="utf-8")
async def handle_vpn_usage_page(_: web.Request) -> web.Response:
return web.Response(text=html_vpn_usage(), content_type="text/html", charset="utf-8")
async def handle_rules_page(_: web.Request) -> web.Response:
return web.Response(text=html_site_rules(), content_type="text/html", charset="utf-8")
async def handle_refund_page(_: web.Request) -> web.Response:
return web.Response(text=html_refund(), content_type="text/html", charset="utf-8")
async def handle_delivery_page(_: web.Request) -> web.Response:
return web.Response(text=html_delivery(), content_type="text/html", charset="utf-8")
async def handle_contacts_page(_: web.Request) -> web.Response:
return web.Response(text=html_contacts(), content_type="text/html", charset="utf-8")
async def handle_help_page(_: web.Request) -> web.Response:
return web.Response(text=html_help(), content_type="text/html", charset="utf-8")
async def handle_support_page(_: web.Request) -> web.Response:
return web.Response(text=html_support(), content_type="text/html", charset="utf-8")
async def handle_cookies_page(_: web.Request) -> web.Response:
return web.Response(text=html_cookies(), content_type="text/html", charset="utf-8")
async def handle_status_page(_: web.Request) -> web.Response:
return web.Response(text=html_status(), content_type="text/html", charset="utf-8")
def _require_admin(request: web.Request):
from webapp_admin_routes import _require_admin as ra
return ra(request)
async def handle_admin_promos_list(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
db: Database = request.app["db"]
rows = db.list_promo_codes()
items = []
for r in rows:
items.append(
{
"id": r["id"],
"code": r["code"],
"kind": r["kind"],
"value": float(r["value"]),
"max_uses": r.get("max_uses"),
"used_count": int(r.get("used_count") or 0),
"tariff_id": r.get("tariff_id"),
"active": bool(r.get("active")),
"expires_at": r["expires_at"].isoformat()
if r.get("expires_at") and hasattr(r["expires_at"], "isoformat")
else None,
"note": r.get("note"),
}
)
return web.json_response({"ok": True, "promos": items})
async def handle_admin_promos_create(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
db: Database = request.app["db"]
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
try:
row = db.create_promo_code(
code=str(body.get("code") or ""),
kind=str(body.get("kind") or "percent"),
value=float(body.get("value") or 0),
max_uses=int(body["max_uses"]) if body.get("max_uses") not in (None, "") else None,
tariff_id=str(body.get("tariff_id") or "").strip() or None,
note=str(body.get("note") or "").strip() or None,
)
except (AuthError, ValueError) as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=400)
return web.json_response({"ok": True, "promo": {"id": row["id"], "code": row["code"]}})
async def handle_admin_promos_toggle(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
db: Database = request.app["db"]
promo_id = int(request.match_info["promo_id"])
try:
body = await request.json()
except json.JSONDecodeError:
body = {}
db.set_promo_active(promo_id, bool(body.get("active", True)))
return web.json_response({"ok": True})
async def handle_admin_user_ban(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
db: Database = request.app["db"]
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
tid = int(body.get("telegram_id") or 0)
banned = bool(body.get("banned"))
user = db.set_banned(tid, banned)
return web.json_response({"ok": True, "user": _user_payload(user), "banned": user.banned})
async def handle_admin_user_delete(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
db: Database = request.app["db"]
settings: Settings = request.app["settings"]
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
try:
tid = int(body.get("telegram_id") or 0)
except (TypeError, ValueError):
return web.json_response({"ok": False, "error": "bad telegram_id"}, status=400)
if not tid:
return web.json_response({"ok": False, "error": "Укажите telegram_id"}, status=400)
if not body.get("confirm"):
return web.json_response(
{"ok": False, "error": "Нужно подтверждение (confirm=true)"},
status=400,
)
if tid in (settings.admin_ids or ()):
return web.json_response(
{"ok": False, "error": "Нельзя удалить пользователя из ADMIN_IDS"},
status=400,
)
user = db.get_user(tid)
if user is None:
return web.json_response({"ok": False, "error": "Пользователь не найден"}, status=404)
label = user.display_name
try:
ok = db.delete_user(tid)
except Exception as exc: # noqa: BLE001
logger.exception("admin delete user failed tid=%s", tid)
return web.json_response({"ok": False, "error": str(exc)}, status=500)
if not ok:
return web.json_response({"ok": False, "error": "Пользователь не найден"}, status=404)
return web.json_response(
{
"ok": True,
"telegram_id": tid,
"message": f"Удалён: {label} (ID {tid})",
}
)
async def handle_admin_payment_journal(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
db: Database = request.app["db"]
rows = db.list_payment_events(limit=100)
items = []
for r in rows:
items.append(
{
"id": r["id"],
"provider": r["provider"],
"event_type": r["event_type"],
"telegram_id": r.get("telegram_id"),
"amount": float(r["amount"]) if r.get("amount") is not None else None,
"invoice_ref": r.get("invoice_ref"),
"status": r.get("status"),
"meta": r.get("meta"),
"created_at": r["created_at"].isoformat()
if hasattr(r["created_at"], "isoformat")
else str(r["created_at"]),
}
)
return web.json_response({"ok": True, "events": items})
async def handle_admin_backups_list(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
return web.json_response({"ok": True, "backups": list_backups()})
async def handle_admin_backups_run(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
path = await run_pg_dump_backup(compose_dir=ROOT_DIR)
except Exception as exc: # noqa: BLE001
logger.exception("backup failed")
return web.json_response({"ok": False, "error": str(exc)}, status=500)
return web.json_response({"ok": True, "file": path.name, "backups": list_backups()})
async def handle_admin_backups_download(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
name = request.match_info.get("name") or ""
try:
path = resolve_backup_path(name)
except (ValueError, FileNotFoundError) as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=404)
return web.FileResponse(
path,
headers={
"Content-Type": "application/gzip",
"Content-Disposition": f'attachment; filename="{path.name}"',
},
)
async def handle_admin_backups_delete(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
name = request.match_info.get("name") or ""
try:
delete_backup(name)
except (ValueError, FileNotFoundError) as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=404)
return web.json_response({"ok": True, "backups": list_backups()})
async def handle_admin_backups_delete_post(request: web.Request) -> web.Response:
"""Удаление через POST — надёжнее, чем DELETE за прокси."""
denied = _require_admin(request)
if denied:
return denied
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
name = str(body.get("name") or "").strip()
if not name:
return web.json_response({"ok": False, "error": "Укажите name"}, status=400)
try:
delete_backup(name)
except (ValueError, FileNotFoundError) as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=404)
return web.json_response({"ok": True, "backups": list_backups(), "message": f"Удалён {name}"})
async def handle_admin_backups_restore(request: web.Request) -> web.Response:
"""Восстановить БД из сохранённого бэкапа или загруженного файла (multipart)."""
denied = _require_admin(request)
if denied:
return denied
content_type = (request.content_type or "").lower()
safety_name = None
try:
# Сначала страховочный бэкап текущего состояния
try:
safety = await run_pg_dump_backup(compose_dir=ROOT_DIR)
safety_name = safety.name
except Exception as exc: # noqa: BLE001
logger.warning("pre-restore safety backup failed: %s", exc)
if "multipart" in content_type:
reader = await request.multipart()
confirm = False
file_bytes: bytes | None = None
filename = "upload.sql.gz"
while True:
part = await reader.next()
if part is None:
break
if part.name == "confirm":
confirm = (await part.text()).strip().lower() in {"1", "true", "yes", "on"}
elif part.name == "file":
filename = Path(part.filename or filename).name
file_bytes = await part.read(decode=False)
if not confirm:
return web.json_response(
{"ok": False, "error": "Нужно подтверждение restore (confirm=true)"},
status=400,
)
if not file_bytes:
return web.json_response({"ok": False, "error": "Файл не передан"}, status=400)
await restore_from_bytes(file_bytes, filename=filename)
try:
await save_uploaded_backup(file_bytes, original_name=filename)
except Exception: # noqa: BLE001
pass
else:
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
if not body.get("confirm"):
return web.json_response(
{"ok": False, "error": "Нужно подтверждение restore (confirm=true)"},
status=400,
)
name = str(body.get("name") or "").strip()
if not name:
return web.json_response({"ok": False, "error": "Укажите name бэкапа"}, status=400)
await restore_from_backup_name(name)
except Exception as exc: # noqa: BLE001
logger.exception("restore failed")
return web.json_response(
{
"ok": False,
"error": str(exc),
"safety_backup": safety_name,
},
status=500,
)
return web.json_response(
{
"ok": True,
"message": "База восстановлена. Перезапустите bot при необходимости.",
"safety_backup": safety_name,
"backups": list_backups(),
}
)
async def handle_admin_backups_upload(request: web.Request) -> web.Response:
"""Только сохранить файл в список бэкапов (без restore)."""
denied = _require_admin(request)
if denied:
return denied
if "multipart" not in (request.content_type or "").lower():
return web.json_response({"ok": False, "error": "ожидается multipart"}, status=400)
reader = await request.multipart()
file_bytes: bytes | None = None
filename = "upload.sql.gz"
while True:
part = await reader.next()
if part is None:
break
if part.name == "file":
filename = Path(part.filename or filename).name
file_bytes = await part.read(decode=False)
if not file_bytes:
return web.json_response({"ok": False, "error": "Файл не передан"}, status=400)
try:
path = await save_uploaded_backup(file_bytes, original_name=filename)
except Exception as exc: # noqa: BLE001
return web.json_response({"ok": False, "error": str(exc)}, status=400)
return web.json_response({"ok": True, "file": path.name, "backups": list_backups()})
async def handle_admin_growth_get(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
db: Database = request.app["db"]
return web.json_response(
{
"ok": True,
"settings": {
"referral_bonus_referrer": db.get_setting("referral_bonus_referrer", "50"),
"referral_bonus_referee": db.get_setting("referral_bonus_referee", "50"),
"trial_days": db.get_setting("trial_days", "3"),
"trial_tariff_id": db.get_setting("trial_tariff_id", ""),
},
}
)
async def handle_admin_growth_set(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
db: Database = request.app["db"]
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
mapping = {
"referral_bonus_referrer": "referral_bonus_referrer",
"referral_bonus_referee": "referral_bonus_referee",
"trial_days": "trial_days",
"trial_tariff_id": "trial_tariff_id",
}
data = {}
for k, sk in mapping.items():
if k in body and body[k] is not None:
data[sk] = str(body[k]).strip()
if data:
db.set_settings_map(data)
return await handle_admin_growth_get(request)
+164
View File
@@ -0,0 +1,164 @@
"""WebAuthn / Passkey helpers."""
from __future__ import annotations
import json
from typing import Any
from urllib.parse import urlparse
from webauthn import (
generate_authentication_options,
generate_registration_options,
options_to_json,
verify_authentication_response,
verify_registration_response,
)
from webauthn.helpers import (
base64url_to_bytes,
bytes_to_base64url,
parse_authentication_credential_json,
parse_registration_credential_json,
)
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
AuthenticatorTransport,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
from config import Settings
def rp_id_from_settings(settings: Settings) -> str:
raw = (settings.webapp_url or "").strip()
if not raw:
raise ValueError("WEBAPP_URL не задан — Passkey недоступен")
host = urlparse(raw).hostname
if not host:
raise ValueError("Некорректный WEBAPP_URL")
return host
def expected_origin(settings: Settings) -> str:
origin = (settings.webapp_url or "").strip().rstrip("/")
if not origin:
raise ValueError("WEBAPP_URL не задан")
return origin
def challenge_from_client_data(client_data_b64: str) -> str:
raw = base64url_to_bytes(client_data_b64)
data = json.loads(raw.decode("utf-8"))
challenge = data.get("challenge")
if not challenge:
raise ValueError("challenge missing")
return str(challenge)
def build_registration_options(
settings: Settings,
*,
user_id: int,
user_name: str,
user_display_name: str,
existing_credential_ids: list[bytes],
) -> tuple[dict[str, Any], bytes]:
options = generate_registration_options(
rp_id=rp_id_from_settings(settings),
rp_name="VPN Service",
user_id=str(user_id).encode("utf-8"),
user_name=user_name[:64] or f"user-{user_id}",
user_display_name=user_display_name[:64] or user_name,
exclude_credentials=[
PublicKeyCredentialDescriptor(id=cid) for cid in existing_credential_ids
],
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.PREFERRED,
),
)
challenge = options.challenge
return json.loads(options_to_json(options)), challenge
def verify_registration(
settings: Settings,
*,
credential: dict[str, Any],
expected_challenge: bytes,
) -> Any:
parsed = parse_registration_credential_json(json.dumps(credential))
return verify_registration_response(
credential=parsed,
expected_challenge=expected_challenge,
expected_rp_id=rp_id_from_settings(settings),
expected_origin=expected_origin(settings),
)
def build_authentication_options(
settings: Settings,
*,
allow_credential_ids: list[bytes] | None = None,
) -> tuple[dict[str, Any], bytes]:
allow = None
if allow_credential_ids:
allow = [PublicKeyCredentialDescriptor(id=cid) for cid in allow_credential_ids]
options = generate_authentication_options(
rp_id=rp_id_from_settings(settings),
allow_credentials=allow,
user_verification=UserVerificationRequirement.PREFERRED,
)
return json.loads(options_to_json(options)), options.challenge
def verify_authentication(
settings: Settings,
*,
credential: dict[str, Any],
expected_challenge: bytes,
credential_public_key: bytes,
credential_current_sign_count: int,
) -> Any:
parsed = parse_authentication_credential_json(json.dumps(credential))
return verify_authentication_response(
credential=parsed,
expected_challenge=expected_challenge,
expected_rp_id=rp_id_from_settings(settings),
expected_origin=expected_origin(settings),
credential_public_key=credential_public_key,
credential_current_sign_count=credential_current_sign_count,
)
def credential_id_b64(raw: bytes) -> str:
return bytes_to_base64url(raw)
def credential_id_bytes(b64: str) -> bytes:
return base64url_to_bytes(b64)
def parse_transports(raw: Any) -> list[str]:
if not raw:
return []
if isinstance(raw, list):
return [str(x) for x in raw]
try:
data = json.loads(raw) if isinstance(raw, str) else []
except json.JSONDecodeError:
return []
if isinstance(data, list):
return [str(x) for x in data]
return []
def transports_to_enum(names: list[str]) -> list[AuthenticatorTransport]:
out: list[AuthenticatorTransport] = []
for name in names:
try:
out.append(AuthenticatorTransport(name))
except ValueError:
continue
return out
+643
View File
@@ -0,0 +1,643 @@
"""Клиент API панели 3x-ui (v3.5.0+): login, inbounds, WireGuard clients."""
from __future__ import annotations
import json
import logging
import re
import secrets
import string
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote, urljoin, urlparse
import httpx
logger = logging.getLogger(__name__)
# 3x-ui 3.5.0: WireGuard — multi-client через settings.clients + addClient
SUPPORTED_PANEL = "3.5.0"
class XuiError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class XuiInbound:
id: int
remark: str
protocol: str
port: int
enable: bool
clients_count: int = 0
listen: str = ""
@property
def is_wireguard(self) -> bool:
return (self.protocol or "").lower() in {"wireguard", "wg"}
@dataclass(frozen=True, slots=True)
class XuiConnectionInfo:
ok: bool
version: str | None
inbounds_total: int
wireguard: list[XuiInbound]
message: str = ""
def _normalize_base_url(url: str) -> str:
raw = (url or "").strip().rstrip("/")
if not raw:
raise XuiError("API URL пуст")
if not raw.startswith(("http://", "https://")):
raw = "https://" + raw
parsed = urlparse(raw)
if not parsed.netloc:
raise XuiError("Некорректный API URL")
return raw
def _new_sub_id(length: int = 16) -> str:
alphabet = string.ascii_lowercase + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def build_subscription_link(subscription_base: str, sub_id: str) -> str:
"""Склеивает ручной base URL подписки с subId клиента."""
base = (subscription_base or "").strip().rstrip("/")
sid = (sub_id or "").strip().lstrip("/")
if not base or not sid:
return ""
return f"{base}/{sid}"
def _parse_settings(raw: Any) -> dict[str, Any]:
if isinstance(raw, dict):
return raw
if isinstance(raw, str) and raw.strip():
try:
data = json.loads(raw)
return data if isinstance(data, dict) else {}
except json.JSONDecodeError:
return {}
return {}
def _clients_count(settings: dict[str, Any]) -> int:
clients = settings.get("clients")
if isinstance(clients, list):
return len(clients)
peers = settings.get("peers")
if isinstance(peers, list):
return len(peers)
return 0
def _inbound_from_obj(obj: dict[str, Any]) -> XuiInbound:
settings = _parse_settings(obj.get("settings"))
return XuiInbound(
id=int(obj.get("id") or 0),
remark=str(obj.get("remark") or ""),
protocol=str(obj.get("protocol") or ""),
port=int(obj.get("port") or 0),
enable=bool(obj.get("enable", True)),
clients_count=_clients_count(settings),
listen=str(obj.get("listen") or ""),
)
class XuiClient:
"""Сессия к одной панели 3x-ui.
Предпочтительно: API Token (Settings Security API Token) как Bearer.
Fallback: cookie после POST /login (username + password).
"""
def __init__(
self,
api_url: str,
username: str = "",
password: str = "",
*,
api_key: str = "",
verify_ssl: bool = True,
timeout: float = 25.0,
) -> None:
self.base_url = _normalize_base_url(api_url)
self.username = (username or "").strip()
self.password = password or ""
self.api_key = (api_key or "").strip()
self.verify_ssl = verify_ssl
self.timeout = timeout
self._client: httpx.AsyncClient | None = None
self._authed = False
async def __aenter__(self) -> XuiClient:
await self.open()
return self
async def __aexit__(self, *args: Any) -> None:
await self.close()
async def open(self) -> None:
if self._client is not None:
return
headers: dict[str, str] = {"Accept": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
self._client = httpx.AsyncClient(
base_url=self.base_url,
timeout=self.timeout,
verify=self.verify_ssl,
follow_redirects=True,
headers=headers,
)
async def close(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
self._authed = False
def _http(self) -> httpx.AsyncClient:
if self._client is None:
raise XuiError("Клиент не открыт — вызовите open() или используйте async with")
return self._client
async def _request(
self,
method: str,
path: str,
*,
json_body: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
) -> dict[str, Any]:
http = self._http()
url = path if path.startswith("/") else f"/{path}"
try:
resp = await http.request(method, url, json=json_body, data=data)
except httpx.RequestError as exc:
raise XuiError(f"Сеть: {exc}") from exc
try:
payload = resp.json()
except json.JSONDecodeError:
text = (resp.text or "")[:200].replace("\n", " ")
hint = ""
if resp.status_code == 404:
hint = (
" (часто: неверный API Token — панель отвечает 404; "
"или устаревший путь API)"
)
raise XuiError(
f"Не JSON ответ ({resp.status_code}){hint}: {text}"
) from None
if not isinstance(payload, dict):
raise XuiError("Неожиданный формат ответа панели")
if resp.status_code >= 400:
msg = payload.get("msg") or payload.get("message") or resp.reason_phrase
raise XuiError(f"HTTP {resp.status_code}: {msg}")
# 3x-ui обычно: {success: bool, msg: str, obj: ...}
if "success" in payload and payload.get("success") is False:
raise XuiError(str(payload.get("msg") or "Ошибка панели"))
return payload
async def ensure_auth(self) -> None:
"""Авторизация: Bearer API key или login cookie."""
if self._authed:
return
await self.open()
if self.api_key:
# Bearer уже в headers — login не нужен (обходит CSRF на /panel/api/*)
self._authed = True
return
await self.login()
self._authed = True
async def login(self) -> None:
if not self.username or not self.password:
raise XuiError("Нужен API Token или пара логин/пароль")
# Классический form login; часть сборок принимает JSON
try:
await self._request(
"POST",
"/login",
data={"username": self.username, "password": self.password},
)
except XuiError:
await self._request(
"POST",
"/login",
json_body={"username": self.username, "password": self.password},
)
async def get_version(self) -> str | None:
for path in ("/panel/api/server/status", "/panel/api/server/getConfigJson"):
try:
payload = await self._request("GET", path)
except XuiError:
continue
obj = payload.get("obj")
if isinstance(obj, dict):
for key in ("version", "xrayVersion", "appVersion"):
val = obj.get(key)
if val:
return str(val)
# status иногда кладёт version на верхний уровень obj как строку в nested
if isinstance(obj, dict) and "cpu" in obj:
# нет версии в status — ок
return None
return None
async def list_inbounds(self) -> list[XuiInbound]:
payload = await self._request("GET", "/panel/api/inbounds/list")
obj = payload.get("obj")
items: list[Any]
if isinstance(obj, list):
items = obj
elif isinstance(obj, dict):
items = obj.get("inbounds") or obj.get("list") or []
else:
items = []
out: list[XuiInbound] = []
for item in items:
if isinstance(item, dict) and item.get("id") is not None:
out.append(_inbound_from_obj(item))
return out
async def get_inbound(self, inbound_id: int) -> dict[str, Any]:
payload = await self._request("GET", f"/panel/api/inbounds/get/{int(inbound_id)}")
obj = payload.get("obj")
if not isinstance(obj, dict):
raise XuiError(f"Инбаунд #{inbound_id} не найден")
return obj
async def list_wireguard_inbounds(self) -> list[XuiInbound]:
return [i for i in await self.list_inbounds() if i.is_wireguard]
async def add_wireguard_client(
self,
inbound_id: int,
*,
email: str,
sub_id: str | None = None,
tg_id: int | str = 0,
expiry_ms: int = 0,
total_gb: int = 0,
limit_ip: int = 0,
enable: bool = True,
comment: str = "",
) -> dict[str, Any]:
"""Добавляет WireGuard-клиента (3x-ui 3.5.0: POST /panel/api/clients/add)."""
await self.ensure_auth()
sid = (sub_id or "").strip() or _new_sub_id()
try:
tg_num = int(tg_id) if tg_id not in ("", None) else 0
except (TypeError, ValueError):
tg_num = 0
client: dict[str, Any] = {
"email": email,
"enable": bool(enable),
"expiryTime": int(expiry_ms),
"totalGB": int(total_gb),
"limitIp": int(limit_ip),
"tgId": tg_num,
"subId": sid,
"comment": comment or "",
"reset": 0,
}
modern_body = {
"client": client,
"inboundIds": [int(inbound_id)],
}
try:
payload = await self._request(
"POST",
"/panel/api/clients/add",
json_body=modern_body,
)
except XuiError as modern_exc:
# Fallback для старых панелей с /inbounds/addClient
legacy_body = {
"id": int(inbound_id),
"settings": json.dumps({"clients": [client]}, ensure_ascii=False),
}
try:
payload = await self._request(
"POST",
"/panel/api/inbounds/addClient",
json_body=legacy_body,
)
except XuiError:
raise modern_exc from None
created = await self.get_client(inbound_id, email=email)
if created and created.get("subId"):
sid = str(created.get("subId") or sid)
return {
"sub_id": sid,
"email": email,
"client": created,
"response": payload,
}
def _find_client_in_inbound(
self, inbound: dict[str, Any], *, email: str
) -> dict[str, Any] | None:
settings = _parse_settings(inbound.get("settings"))
clients = settings.get("clients")
if not isinstance(clients, list):
return None
email_l = email.strip().lower()
for client in clients:
if isinstance(client, dict) and str(client.get("email") or "").strip().lower() == email_l:
return client
return None
def _path_email(self, email: str) -> str:
"""Email в path: простые id без кодирования, иначе PathEscape-совместимо."""
raw = (email or "").strip()
if re.fullmatch(r"[A-Za-z0-9_.-]+", raw or ""):
return raw
return quote(raw, safe="._-~")
@staticmethod
def _sanitize_client_payload(
client: dict[str, Any],
*,
enable: bool | None = None,
email: str | None = None,
) -> dict[str, Any]:
"""Только поля клиента — без traffic/inboundIds (иначе update ломается)."""
allowed = {
"email",
"enable",
"expiryTime",
"totalGB",
"limitIp",
"tgId",
"subId",
"comment",
"reset",
"flow",
"password",
"id",
"security",
"encryption",
"privateKey",
"publicKey",
"preSharedKey",
"allowedIPs",
"keepAlive",
"method",
"auth",
}
out: dict[str, Any] = {}
for key, value in client.items():
if key not in allowed or value is None:
continue
if key == "allowedIPs" and not isinstance(value, list):
continue
if key != "allowedIPs" and isinstance(value, (dict, list)):
continue
out[key] = value
# Client.id в 3x-ui — строка (UUID и т.п.), не numeric id из таблицы clients
raw_id = out.get("id", None)
if raw_id is None and client.get("uuid"):
raw_id = client.get("uuid")
if isinstance(raw_id, int):
# числовой PK таблицы clients нельзя слать как Client.id
out.pop("id", None)
if client.get("uuid"):
out["id"] = str(client["uuid"])
elif raw_id is not None:
out["id"] = str(raw_id)
if email:
out["email"] = email
elif "email" not in out and client.get("email"):
out["email"] = client["email"]
if enable is not None:
out["enable"] = bool(enable)
# tgId панель иногда ждёт число
if "tgId" in out:
try:
out["tgId"] = int(out["tgId"] or 0)
except (TypeError, ValueError):
out["tgId"] = 0
return out
async def get_client(
self, inbound_id: int, *, email: str
) -> dict[str, Any] | None:
await self.ensure_auth()
# Modern API
try:
payload = await self._request(
"GET",
f"/panel/api/clients/get/{self._path_email(email)}",
)
obj = payload.get("obj")
if isinstance(obj, dict):
client = obj.get("client") if isinstance(obj.get("client"), dict) else obj
if isinstance(client, dict) and client.get("email"):
return client
except XuiError:
pass
inbound = await self.get_inbound(inbound_id)
return self._find_client_in_inbound(inbound, email=email)
async def update_client(
self,
inbound_id: int,
client_id: str,
client: dict[str, Any],
) -> dict[str, Any]:
"""Обновление клиента: modern clients/update → legacy updateClient → inbound update."""
await self.ensure_auth()
email = str(client.get("email") or client_id).strip()
clean = self._sanitize_client_payload(client, email=email)
# 1) Modern API
for path in (
f"/panel/api/clients/update/{self._path_email(email)}",
f"/panel/api/clients/update/{self._path_email(email)}?inboundIds={int(inbound_id)}",
):
try:
return await self._request("POST", path, json_body=clean)
except XuiError:
pass
# 2) Legacy inbounds/updateClient
legacy_body = {
"id": int(inbound_id),
"settings": json.dumps({"clients": [clean]}, ensure_ascii=False),
}
for client_key in (email, str(clean.get("id") or ""), client_id):
if not client_key:
continue
path = f"/panel/api/inbounds/updateClient/{self._path_email(str(client_key))}"
try:
return await self._request("POST", path, json_body=legacy_body)
except XuiError:
pass
try:
return await self._request(
"POST",
path,
data={
"id": str(int(inbound_id)),
"settings": legacy_body["settings"],
},
)
except XuiError:
pass
# 3) Fallback: правим поля прямо в settings inbound
return await self._update_inbound_client_fields(
inbound_id,
email=email,
patch=clean,
)
async def _update_inbound_client_fields(
self,
inbound_id: int,
*,
email: str,
patch: dict[str, Any],
) -> dict[str, Any]:
inbound = await self.get_inbound(inbound_id)
settings = _parse_settings(inbound.get("settings"))
clients = settings.get("clients")
if not isinstance(clients, list):
raise XuiError(f"В inbound #{inbound_id} нет clients[]")
email_l = email.strip().lower()
found = False
new_clients: list[Any] = []
for item in clients:
if not isinstance(item, dict):
new_clients.append(item)
continue
if str(item.get("email") or "").strip().lower() == email_l:
merged = dict(item)
merged.update(patch)
merged["email"] = item.get("email") or email
new_clients.append(merged)
found = True
else:
new_clients.append(item)
if not found:
raise XuiError(f"Клиент {email} не найден в inbound #{inbound_id}")
settings["clients"] = new_clients
payload = dict(inbound)
payload["id"] = int(inbound_id)
payload["settings"] = json.dumps(settings, ensure_ascii=False)
for key in ("streamSettings", "sniffing", "allocate"):
val = payload.get(key)
if isinstance(val, (dict, list)):
payload[key] = json.dumps(val, ensure_ascii=False)
# убрать служебные поля ответа get
for key in ("clientStats", "clients", "obj"):
payload.pop(key, None)
return await self._request(
"POST",
f"/panel/api/inbounds/update/{int(inbound_id)}",
json_body=payload,
)
async def set_client_enabled(
self,
inbound_id: int,
*,
email: str,
enable: bool,
) -> dict[str, Any]:
await self.ensure_auth()
current = await self.get_client(inbound_id, email=email)
if not current:
# всё равно пробуем через inbound settings
return await self._update_inbound_client_fields(
inbound_id,
email=email,
patch={"enable": bool(enable), "email": email},
)
clean = self._sanitize_client_payload(current, enable=enable, email=email)
try:
return await self.update_client(inbound_id, email, clean)
except XuiError:
return await self._update_inbound_client_fields(
inbound_id,
email=email,
patch=clean,
)
async def delete_client_by_email(self, inbound_id: int, email: str) -> dict[str, Any]:
await self.ensure_auth()
try:
return await self._request(
"POST",
f"/panel/api/clients/del/{self._path_email(email)}",
)
except XuiError:
path = (
f"/panel/api/inbounds/{int(inbound_id)}/"
f"delClientByEmail/{self._path_email(email)}"
)
try:
return await self._request("POST", path)
except XuiError:
# отвязка / disable как мягкое удаление
return await self._update_inbound_client_fields(
inbound_id,
email=email,
patch={"enable": False, "email": email},
)
async def test_connection(self) -> XuiConnectionInfo:
await self.open()
try:
await self.ensure_auth()
inbounds = await self.list_inbounds()
wg = [i for i in inbounds if i.is_wireguard]
version = None
try:
version = await self.get_version()
except XuiError:
version = None
auth_mode = "api_key" if self.api_key else "login"
msg = (
f"OK · {auth_mode} · inbounds {len(inbounds)} · WireGuard {len(wg)}"
+ (f" · panel {version}" if version else f" · target {SUPPORTED_PANEL}+")
)
return XuiConnectionInfo(
ok=True,
version=version,
inbounds_total=len(inbounds),
wireguard=wg,
message=msg,
)
except XuiError as exc:
return XuiConnectionInfo(
ok=False,
version=None,
inbounds_total=0,
wireguard=[],
message=str(exc),
)
def subscription_preview(subscription_url: str, sample_sub_id: str = "exampleSubId") -> str:
link = build_subscription_link(subscription_url, sample_sub_id)
if link:
return link
return urljoin(_normalize_base_url(subscription_url or "https://example.invalid") + "/", sample_sub_id)
+588
View File
@@ -0,0 +1,588 @@
"""Покупка и управление WireGuard-конфигами 3x-ui (лимит 10 при активной подписке)."""
from __future__ import annotations
import logging
import secrets
import string
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from remnawave import RemnawaveSDK
from services.db import Database
from services.purchase import get_my_subscription_info
from services.remnawave_users import find_panel_user
from services.traffic import (
BASE_TRAFFIC_GB,
ensure_base_limit_bytes,
format_gb,
gb_to_bytes,
)
from services.xui_client import XuiClient, XuiError, build_subscription_link
logger = logging.getLogger(__name__)
MAX_WG_CONFIGS = 10
SETTING_ENABLED = "xui_wg_purchase_enabled"
SETTING_PRICE = "xui_wg_config_price"
DEFAULT_PRICE = 0.0
def _truthy(value: str) -> bool:
return (value or "").strip().lower() in ("1", "true", "yes", "on", "да")
def _as_utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
@dataclass(slots=True)
class WgPurchaseSettings:
enabled: bool = False
price: float = DEFAULT_PRICE
max_configs: int = MAX_WG_CONFIGS
def as_dict(self) -> dict[str, Any]:
return {
"enabled": self.enabled,
"price": self.price,
"price_label": _format_price(self.price),
"max_configs": self.max_configs,
}
@dataclass(slots=True)
class WgActionResult:
ok: bool
message: str
balance: float = 0.0
config: dict[str, Any] | None = None
configs: list[dict[str, Any]] | None = None
def _format_price(price: float) -> str:
if price <= 0:
return "бесплатно"
if price == int(price):
return f"{int(price)}"
return f"{price:.2f}"
def load_wg_purchase_settings(db: Database) -> WgPurchaseSettings:
raw = db.get_settings_map([SETTING_ENABLED, SETTING_PRICE])
price_raw = (raw.get(SETTING_PRICE) or "").strip()
try:
price = float(price_raw) if price_raw else DEFAULT_PRICE
except ValueError:
price = DEFAULT_PRICE
return WgPurchaseSettings(
enabled=_truthy(raw.get(SETTING_ENABLED, "")),
price=max(0.0, price),
max_configs=MAX_WG_CONFIGS,
)
def save_wg_purchase_settings(
db: Database,
*,
enabled: bool | None = None,
price: float | None = None,
) -> WgPurchaseSettings:
data: dict[str, str] = {}
if enabled is not None:
data[SETTING_ENABLED] = "1" if enabled else "0"
if price is not None:
data[SETTING_PRICE] = str(max(0.0, float(price)))
if data:
db.set_settings_map(data)
return load_wg_purchase_settings(db)
def _client_from_server(row: dict[str, Any]) -> XuiClient:
return XuiClient(
str(row["api_url"]),
username=str(row.get("username") or ""),
password=str(row.get("password") or ""),
api_key=str(row.get("api_key") or ""),
verify_ssl=bool(row.get("verify_ssl", True)),
)
def _new_email(telegram_id: int) -> str:
suffix = "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(6))
return f"wg_{telegram_id}_{suffix}"
def serialize_wg_config(row: dict[str, Any], server: dict[str, Any] | None = None) -> dict[str, Any]:
expire = row.get("expire_at")
traffic_bytes = int(row.get("traffic_bytes") or 0)
if traffic_bytes <= 0:
traffic_bytes = int(gb_to_bytes(BASE_TRAFFIC_GB))
return {
"id": int(row["id"]),
"server_id": int(row["server_id"]),
"server_name": (server or {}).get("name") or row.get("server_name") or "",
"email": row.get("email") or "",
"sub_id": row.get("sub_id") or "",
"subscription_url": row.get("subscription_url") or "",
"remark": row.get("remark") or "",
"enabled": bool(row.get("enabled", True)),
"expire_at": expire.isoformat() if isinstance(expire, datetime) else expire,
"traffic_bytes": traffic_bytes,
"traffic_label": format_gb(traffic_bytes / (1024**3), digits=0),
"qr_url": f"/api/wg/configs/{int(row['id'])}/qr",
"created_at": row["created_at"].isoformat()
if isinstance(row.get("created_at"), datetime)
else row.get("created_at"),
}
async def _subscription_limits(
sdk: RemnawaveSDK,
telegram_id: int,
*,
login: str | None = None,
db: Database | None = None,
) -> tuple[datetime, int]:
"""Срок и лимит трафика как у подписки в кабинете (база ≥ 1000 ГБ)."""
info = await get_my_subscription_info(sdk, telegram_id, login=login, db=db)
if not info.expire_at:
raise RuntimeError("Нет срока подписки")
expire = _as_utc(info.expire_at)
panel = await find_panel_user(sdk, telegram_id, login=login)
if panel is not None:
traffic_bytes = int(ensure_base_limit_bytes(getattr(panel, "traffic_limit_bytes", 0)))
elif info.traffic:
traffic_bytes = int(ensure_base_limit_bytes(info.traffic.limit_bytes))
else:
traffic_bytes = int(gb_to_bytes(BASE_TRAFFIC_GB))
return expire, traffic_bytes
async def subscription_is_active(
sdk: RemnawaveSDK,
telegram_id: int,
*,
login: str | None = None,
db: Database | None = None,
) -> tuple[bool, str]:
info = await get_my_subscription_info(sdk, telegram_id, login=login, db=db)
if not info.has_subscription or not info.expire_at:
return False, "Нет активной подписки. Сначала купите тариф."
now = datetime.now(timezone.utc)
if _as_utc(info.expire_at) <= now:
return (
False,
"Подписка истекла. Продлите или купите тариф — после этого снова можно включать конфиги.",
)
if info.devices and info.devices.over_limit:
return False, "Подписка отключена из‑за превышения устройств."
return True, ""
def list_available_servers(db: Database) -> list[dict[str, Any]]:
rows = db.list_xui_servers(enabled_only=True)
out: list[dict[str, Any]] = []
for r in rows:
if r.get("inbound_id") is None:
continue
if not str(r.get("subscription_url") or "").strip():
continue
if not (str(r.get("api_key") or "").strip() or str(r.get("password") or "").strip()):
continue
out.append(
{
"id": int(r["id"]),
"name": r.get("name") or f"Server #{r['id']}",
"inbound_id": r.get("inbound_id"),
"inbound_remark": r.get("inbound_remark") or "",
}
)
return out
async def purchase_wg_config(
*,
db: Database,
sdk: RemnawaveSDK,
telegram_id: int,
server_id: int,
remark: str = "",
login: str | None = None,
) -> WgActionResult:
settings = load_wg_purchase_settings(db)
bot_user = db.ensure_user(telegram_id)
balance = bot_user.balance
login = login or bot_user.login
if not settings.enabled:
return WgActionResult(
ok=False,
message="Покупка WireGuard-конфигов временно отключена.",
balance=balance,
)
active, reason = await subscription_is_active(
sdk, telegram_id, login=login, db=db
)
if not active:
return WgActionResult(ok=False, message=reason, balance=balance)
count = db.count_xui_wg_configs(telegram_id)
if count >= settings.max_configs:
return WgActionResult(
ok=False,
message=f"Лимит: максимум {settings.max_configs} конфигов на аккаунт.",
balance=balance,
)
server = db.get_xui_server(server_id)
if not server or not server.get("enabled"):
return WgActionResult(ok=False, message="Сервер недоступен.", balance=balance)
inbound_id = server.get("inbound_id")
if inbound_id is None:
return WgActionResult(
ok=False,
message="У сервера не выбран WireGuard inbound.",
balance=balance,
)
sub_base = str(server.get("subscription_url") or "").strip()
if not sub_base:
return WgActionResult(
ok=False,
message="У сервера не задан URL подписки.",
balance=balance,
)
price = float(settings.price)
if price > 0:
ok, balance = db.try_charge(
telegram_id,
price,
kind="wg_config",
meta=f"wg:{server_id}",
)
if not ok:
return WgActionResult(
ok=False,
message=(
f"Недостаточно средств.\n"
f"Нужно: <b>{_format_price(price)}</b>\n"
f"Баланс: <b>{balance:.0f} ₽</b>"
),
balance=balance,
)
email = _new_email(telegram_id)
remark = (remark or "").strip()[:80] or f"WG {bot_user.display_name}"
try:
expire_at, traffic_bytes = await _subscription_limits(
sdk, telegram_id, login=login, db=db
)
expiry_ms = int(expire_at.timestamp() * 1000)
async with _client_from_server(server) as client:
created = await client.add_wireguard_client(
int(inbound_id),
email=email,
tg_id=telegram_id if telegram_id > 0 else 0,
comment=remark,
enable=True,
expiry_ms=expiry_ms,
total_gb=traffic_bytes, # в 3x-ui clients API totalGB — байты
)
sub_id = str(created["sub_id"])
sub_url = build_subscription_link(sub_base, sub_id)
row = db.create_xui_wg_config(
telegram_id=telegram_id,
server_id=server_id,
email=email,
sub_id=sub_id,
subscription_url=sub_url,
remark=remark,
enabled=True,
expire_at=expire_at,
traffic_bytes=traffic_bytes,
)
except Exception as exc: # noqa: BLE001
logger.exception("WG config create failed")
if price > 0:
balance = db.add_balance(
telegram_id,
price,
kind="refund",
meta=f"wg_refund:{server_id}",
)
return WgActionResult(
ok=False,
message=f"Не удалось создать конфиг: {exc}",
balance=balance,
)
cfg = serialize_wg_config(row, server)
expire_str = expire_at.strftime("%d.%m.%Y %H:%M UTC")
return WgActionResult(
ok=True,
message=(
f"WireGuard-конфиг создан"
+ (f" ({_format_price(price)})" if price > 0 else "")
+ f".\nДо: <b>{expire_str}</b>"
+ f"\nТрафик: <b>{format_gb(traffic_bytes / (1024**3), digits=0)}</b>"
+ f".\n🔗 <code>{sub_url}</code>"
),
balance=balance if price <= 0 else balance,
config=cfg,
)
async def set_wg_config_enabled(
*,
db: Database,
telegram_id: int,
config_id: int,
enable: bool,
sdk: RemnawaveSDK | None = None,
login: str | None = None,
) -> WgActionResult:
bot_user = db.ensure_user(telegram_id)
login = login or bot_user.login
row = db.get_xui_wg_config(config_id, telegram_id=telegram_id)
if not row:
return WgActionResult(ok=False, message="Конфиг не найден.", balance=bot_user.balance)
if enable:
if sdk is None:
return WgActionResult(
ok=False,
message="Не удалось проверить подписку.",
balance=bot_user.balance,
)
active, reason = await subscription_is_active(
sdk, telegram_id, login=login, db=db
)
if not active:
return WgActionResult(ok=False, message=reason, balance=bot_user.balance)
server = db.get_xui_server(int(row["server_id"]))
if not server or server.get("inbound_id") is None:
return WgActionResult(
ok=False,
message="Сервер конфига недоступен.",
balance=bot_user.balance,
)
try:
async with _client_from_server(server) as client:
await client.set_client_enabled(
int(server["inbound_id"]),
email=str(row["email"]),
enable=enable,
)
updated = db.set_xui_wg_config_enabled(config_id, telegram_id, enable)
except XuiError as exc:
return WgActionResult(
ok=False,
message=f"Панель: {exc}",
balance=bot_user.balance,
)
except Exception as exc: # noqa: BLE001
logger.exception("WG toggle failed")
return WgActionResult(
ok=False,
message=str(exc),
balance=bot_user.balance,
)
return WgActionResult(
ok=True,
message="Конфиг включён." if enable else "Конфиг выключен.",
balance=bot_user.balance,
config=serialize_wg_config(updated or row, server) if updated or row else None,
)
async def _disable_wg_row(db: Database, row: dict[str, Any]) -> bool:
"""Выключает один WG-конфиг на панели и в БД. True если изменили."""
if not row.get("enabled"):
return False
server = db.get_xui_server(int(row["server_id"]))
if not server or server.get("inbound_id") is None:
db.set_xui_wg_config_enabled(int(row["id"]), int(row["telegram_id"]), False)
return True
try:
async with _client_from_server(server) as client:
await client.set_client_enabled(
int(server["inbound_id"]),
email=str(row["email"]),
enable=False,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"WG auto-disable panel failed config=%s: %s",
row.get("id"),
exc,
)
db.set_xui_wg_config_enabled(int(row["id"]), int(row["telegram_id"]), False)
return True
async def _enable_wg_row(db: Database, row: dict[str, Any]) -> bool:
if row.get("enabled"):
return False
server = db.get_xui_server(int(row["server_id"]))
if not server or server.get("inbound_id") is None:
return False
try:
async with _client_from_server(server) as client:
await client.set_client_enabled(
int(server["inbound_id"]),
email=str(row["email"]),
enable=True,
)
db.set_xui_wg_config_enabled(int(row["id"]), int(row["telegram_id"]), True)
return True
except Exception as exc: # noqa: BLE001
logger.warning(
"WG auto-enable panel failed config=%s: %s",
row.get("id"),
exc,
)
return False
async def enforce_wg_configs_subscription(
*,
db: Database,
sdk: RemnawaveSDK,
telegram_id: int,
login: str | None = None,
) -> dict[str, Any]:
"""В реальном времени: нет подписки → выключить все WG."""
bot_user = db.ensure_user(telegram_id)
login = login or bot_user.login
active, reason = await subscription_is_active(
sdk, telegram_id, login=login, db=db
)
disabled = 0
if not active:
for row in db.list_xui_wg_configs(telegram_id):
if await _disable_wg_row(db, row):
disabled += 1
return {
"subscription_active": active,
"reason": "" if active else reason,
"disabled": disabled,
}
async def restore_wg_configs_after_renew(
*,
db: Database,
telegram_id: int,
) -> int:
"""После продления снова включает ранее отключённые WG-конфиги."""
rows = db.list_xui_wg_configs(telegram_id)
restored = 0
for row in rows:
if row.get("enabled"):
continue
if await _enable_wg_row(db, row):
restored += 1
return restored
async def sync_wg_configs_limits(
*,
db: Database,
telegram_id: int,
expire_at: datetime | None = None,
traffic_bytes: int | None = None,
restore_enabled: bool = False,
) -> int:
"""Обновляет срок/трафик всех WG-конфигов пользователя на панелях 3x-ui."""
if expire_at is None and traffic_bytes is None and not restore_enabled:
return 0
rows = db.list_xui_wg_configs(telegram_id)
updated = 0
expiry_ms = int(_as_utc(expire_at).timestamp() * 1000) if expire_at else None
for row in rows:
server = db.get_xui_server(int(row["server_id"]))
if not server or server.get("inbound_id") is None:
continue
try:
async with _client_from_server(server) as client:
current = await client.get_client(
int(server["inbound_id"]),
email=str(row["email"]),
)
if not current:
continue
patch = dict(current)
patch["email"] = str(row["email"])
if expiry_ms is not None:
patch["expiryTime"] = expiry_ms
if traffic_bytes is not None:
patch["totalGB"] = int(traffic_bytes)
if restore_enabled:
patch["enable"] = True
await client.update_client(
int(server["inbound_id"]),
str(row["email"]),
patch,
)
db.update_xui_wg_config_limits(
int(row["id"]),
expire_at=expire_at,
traffic_bytes=traffic_bytes,
)
if restore_enabled and not row.get("enabled"):
db.set_xui_wg_config_enabled(
int(row["id"]), telegram_id, True
)
updated += 1
except Exception as exc: # noqa: BLE001
logger.warning(
"WG sync limits failed config=%s: %s",
row.get("id"),
exc,
)
return updated
def list_user_wg_configs(db: Database, telegram_id: int) -> list[dict[str, Any]]:
rows = db.list_xui_wg_configs(telegram_id)
servers = {int(s["id"]): s for s in db.list_xui_servers()}
return [serialize_wg_config(r, servers.get(int(r["server_id"]))) for r in rows]
async def delete_wg_config(
*,
db: Database,
telegram_id: int,
config_id: int,
) -> WgActionResult:
bot_user = db.ensure_user(telegram_id)
row = db.get_xui_wg_config(config_id, telegram_id=telegram_id)
if not row:
return WgActionResult(ok=False, message="Конфиг не найден.", balance=bot_user.balance)
server = db.get_xui_server(int(row["server_id"]))
if server and server.get("inbound_id") is not None:
try:
async with _client_from_server(server) as client:
await client.delete_client_by_email(
int(server["inbound_id"]),
str(row["email"]),
)
except Exception as exc: # noqa: BLE001
logger.warning("WG delete on panel failed (will drop locally): %s", exc)
db.delete_xui_wg_config(config_id, telegram_id)
return WgActionResult(
ok=True,
message="Конфиг удалён. Слот освобождён.",
balance=bot_user.balance,
)