from __future__ import annotations import re from uuid import UUID from collections.abc import Mapping from typing import Any import httpx from app.services.remnawave_identity import deterministic_user_uuid, validate_cached_identities as validate_identity_cache from app.schemas.remnawave import HwidDevicesResponse, PaginatedUsers, RemnawaveUser, ResolvedUser, SubscriptionRequestHistory UUID_RE = re.compile( r"^[0-9a-fA-F]{8}-" r"[0-9a-fA-F]{4}-" r"[0-9a-fA-F]{4}-" r"[0-9a-fA-F]{4}-" r"[0-9a-fA-F]{12}$" ) class RemnawaveApiError(RuntimeError): def __init__(self, *, status_code: int, message: str, payload: Any | None = None) -> None: self.status_code = status_code self.message = message self.payload = payload super().__init__(message) class RemnawaveApiClient: def __init__( self, *, base_url: str, api_token: str, caddy_api_key: str = "", timeout_seconds: float = 20.0, client: httpx.AsyncClient | None = None, api_version: int = 2, legacy_user_ids: dict[int, str] | None = None, ) -> None: if api_version not in (2, 3): raise ValueError("Supported Remnawave API versions: 2, 3") self._api_version = api_version self._local_uuid_by_id = { int(user_id): str(UUID(local_uuid)) for user_id, local_uuid in (legacy_user_ids or {}).items() } self._id_by_local_uuid = {value: key for key, value in self._local_uuid_by_id.items()} self._username_by_id: dict[int, str] = {} if len(self._id_by_local_uuid) != len(self._local_uuid_by_id): raise ValueError("Duplicate UUID in legacy identity map") self._managed_client = client is None self._client = client or httpx.AsyncClient( base_url=self.normalize_base_url(base_url), timeout=timeout_seconds, headers=self._build_headers( base_url=base_url, api_token=api_token, caddy_api_key=caddy_api_key, ), ) @staticmethod def normalize_base_url(base_url: str) -> str: normalized = base_url.strip().rstrip("/") if not normalized.endswith("/api"): normalized = f"{normalized}/api" return f"{normalized}/" @staticmethod def unwrap_payload(payload: Any) -> Any: if isinstance(payload, Mapping) and "response" in payload: return payload["response"] return payload @staticmethod def _build_headers( *, base_url: str, api_token: str, caddy_api_key: str, ) -> dict[str, str]: headers: dict[str, str] = {"Accept": "application/json"} if api_token: headers["Authorization"] = ( api_token if api_token.startswith("Bearer ") else f"Bearer {api_token}" ) if caddy_api_key: headers["X-Api-Key"] = caddy_api_key if base_url.startswith("http://"): headers["x-forwarded-proto"] = "https" headers["x-forwarded-for"] = "127.0.0.1" return headers async def close(self) -> None: if self._managed_client: await self._client.aclose() async def validate_cached_identities(self, rows: list[tuple[int, str] | tuple[int, str, str]]) -> int: if self._api_version != 3: raise ValueError("Remote identity validation requires Remnawave v3") users = await self._stream_users() return validate_identity_cache( self._local_uuid_by_id, rows, remote_identities=[(user.id, str(user.uuid), user.short_uuid) for user in users], ) async def get_users_by_telegram_id(self, telegram_id: int) -> list[RemnawaveUser]: if self._api_version == 3: return await self._stream_users(telegram_id=telegram_id) payload = await self._request_json("GET", f"users/by-telegram-id/{telegram_id}") return [RemnawaveUser.model_validate(item) for item in payload] async def get_user_by_uuid(self, user_uuid: str) -> RemnawaveUser: if self._api_version == 3: user_uuid = str(await self._remote_id(user_uuid)) payload = await self._request_json("GET", f"users/{user_uuid}") return RemnawaveUser.model_validate(payload) async def get_user_by_short_uuid(self, short_uuid: str) -> RemnawaveUser: payload = await self._request_json("GET", f"users/by-short-uuid/{short_uuid}") return RemnawaveUser.model_validate(payload) async def get_user_by_username(self, username: str) -> RemnawaveUser: payload = await self._request_json("GET", f"users/by-username/{username}") return RemnawaveUser.model_validate(payload) async def create_user(self, body: dict[str, Any]) -> RemnawaveUser: if self._api_version == 3 and "uuid" in body: raise ValueError("Remnawave v3 does not accept custom user UUIDs") payload = await self._request_json("POST", "users", json=body) return RemnawaveUser.model_validate(payload) async def update_user(self, body: dict[str, Any]) -> RemnawaveUser: if self._api_version == 3 and "uuid" in body: body = dict(body) local_uuid = body.pop("uuid") user_id = await self._remote_id(str(local_uuid)) if "id" in body and body["id"] != user_id: raise ValueError("Conflicting user identifiers") if "username" in body: if user_id not in self._username_by_id: await self.get_user_by_uuid(str(user_id)) if body.pop("username") != self._username_by_id[user_id]: raise ValueError("Remnawave v3 cannot rename a user with PATCH /users") body["id"] = user_id payload = await self._request_json("PATCH", "users", json=body) return RemnawaveUser.model_validate(payload) async def get_all_users(self, *, start: int = 0, size: int = 100) -> PaginatedUsers: payload = await self._request_json( "GET", "users", params={"start": start, "size": size}, ) return PaginatedUsers.model_validate(payload) async def get_user_subscription_history(self, user_uuid: str) -> SubscriptionRequestHistory: if self._api_version == 3: user_uuid = str(await self._remote_id(user_uuid)) payload = await self._request_json( "GET", f"users/{user_uuid}/subscription-request-history", ) return SubscriptionRequestHistory.model_validate(payload) async def resolve_user(self, identifier: str) -> ResolvedUser: body: dict[str, Any] if UUID_RE.match(identifier): body = ({"id": await self._remote_id(identifier)} if self._api_version == 3 else {"uuid": identifier}) elif identifier.isdigit(): body = {"id": int(identifier)} else: try: payload = await self._request_json( "POST", "users/resolve", json={"shortUuid": identifier}, ) return ResolvedUser.model_validate(payload) except RemnawaveApiError as exc: if exc.status_code != 404: raise body = {"username": identifier} payload = await self._request_json("POST", "users/resolve", json=body) return ResolvedUser.model_validate(payload) async def get_user_hwid_devices(self, user_uuid: str) -> HwidDevicesResponse: if self._api_version == 3: user_uuid = str(await self._remote_id(user_uuid)) payload = await self._request_json("GET", f"hwid/devices/{user_uuid}") return HwidDevicesResponse.model_validate(payload) async def delete_user_hwid_device(self, *, user_uuid: str, hwid: str) -> HwidDevicesResponse: user_field = ({"userId": await self._remote_id(user_uuid)} if self._api_version == 3 else {"userUuid": user_uuid}) payload = await self._request_json( "POST", "hwid/devices/delete", json={**user_field, "hwid": hwid}, ) return HwidDevicesResponse.model_validate(payload) async def delete_all_user_hwid_devices(self, user_uuid: str) -> HwidDevicesResponse: user_field = ({"userId": await self._remote_id(user_uuid)} if self._api_version == 3 else {"userUuid": user_uuid}) payload = await self._request_json( "POST", "hwid/devices/delete-all", json=user_field, ) return HwidDevicesResponse.model_validate(payload) async def _request_json( self, method: str, path: str, *, params: dict[str, Any] | None = None, json: dict[str, Any] | None = None, ) -> Any: response = await self._client.request( method=method, url=path.lstrip("/"), params=params, json=json, ) if response.is_error: raise self._build_error(response) try: payload = response.json() except ValueError: return response.text payload = self.unwrap_payload(payload) return self._normalize_v3(payload) if self._api_version == 3 else payload def _local_uuid(self, user_id: int) -> str: """Keep bot DB references stable; these UUIDs are never sent to API v3.""" user_id = int(user_id) if user_id not in self._local_uuid_by_id: # Deterministic local identity for accounts created after migration. self._local_uuid_by_id[user_id] = deterministic_user_uuid(user_id) local_uuid = self._local_uuid_by_id[user_id] self._id_by_local_uuid[local_uuid] = user_id return local_uuid def _normalize_v3(self, payload: Any) -> Any: if isinstance(payload, list): return [self._normalize_v3(item) for item in payload] if not isinstance(payload, dict): return payload result = {key: self._normalize_v3(value) for key, value in payload.items()} if all(key in result for key in ("id", "username", "shortUuid")): result["uuid"] = self._local_uuid(result["id"]) self._username_by_id[int(result["id"])] = result["username"] if "userId" in result and result["userId"] is not None: result["userUuid"] = self._local_uuid(result["userId"]) return result async def _stream_users(self, *, telegram_id: int | None = None) -> list[RemnawaveUser]: params: dict[str, Any] = {"size": 250} if telegram_id is not None: params["telegramId"] = telegram_id users: list[RemnawaveUser] = [] seen_cursors: set[str] = set() while True: payload = await self._request_json("GET", "users/stream", params=params) users.extend(RemnawaveUser.model_validate(item) for item in payload["users"]) if not payload.get("hasMore"): return users cursor = payload.get("nextCursor") if cursor is None or str(cursor) in seen_cursors: raise RemnawaveApiError(status_code=502, message="Invalid users stream cursor") seen_cursors.add(str(cursor)) params["cursor"] = cursor async def _remote_id(self, identifier: str) -> int: if identifier.isdigit(): return int(identifier) try: local_uuid = str(UUID(identifier)) except ValueError as exc: raise RemnawaveApiError(status_code=404, message="Invalid user identifier") from exc if local_uuid not in self._id_by_local_uuid: # Rebuild deterministic aliases after a restart, including an order # saved before its new user reached the bot's local cache. await self._stream_users() if local_uuid not in self._id_by_local_uuid: raise RemnawaveApiError(status_code=404, message="User not found") return self._id_by_local_uuid[local_uuid] def _build_error(self, response: httpx.Response) -> RemnawaveApiError: try: payload = response.json() except ValueError: payload = None message = "Unknown Remnawave API error" if isinstance(payload, Mapping): for key in ("message", "error", "code"): candidate = payload.get(key) if candidate: message = str(candidate) break error_code = payload.get("errorCode") if error_code: message = f"{message} [{error_code}]" error_items = payload.get("errors") if isinstance(error_items, list): details: list[str] = [] for item in error_items: if not isinstance(item, Mapping): continue path_value = item.get("path") if isinstance(path_value, list): path_text = ".".join(str(part) for part in path_value if part is not None) elif path_value is not None: path_text = str(path_value) else: path_text = "" detail_message = str(item.get("message") or item.get("code") or "").strip() if not detail_message: continue if path_text: details.append(f"{path_text}: {detail_message}") else: details.append(detail_message) if details: message = f"{message} | {'; '.join(details)}" elif response.text: message = response.text return RemnawaveApiError( status_code=response.status_code, message=message, payload=payload, )