Files
vpn-telegram-bot/services/xui_wg.py
T

589 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Покупка и управление 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,
)