Initial commit: VPN Telegram bot (sanitized defaults)
This commit is contained in:
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
@@ -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}"
|
||||
@@ -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")),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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). Возврат возможен только при длительном простое по вине сервиса
|
||||
(>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">Краткие сбои и плановое обслуживание обычно отражаются на статус-странице.
|
||||
При длительной недоступности (>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)
|
||||
@@ -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(" ", " ")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
.replace(""", '"')
|
||||
)
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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="Неизвестный тип счёта")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 ""),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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(" ", " ")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
.replace(""", '"')
|
||||
.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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user