352 lines
14 KiB
Python
352 lines
14 KiB
Python
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,
|
|
)
|