"""Покупка и управление 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"Нужно: {_format_price(price)}\n" f"Баланс: {balance:.0f} ₽" ), 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, }