rewrite v3

This commit is contained in:
2026-10-28 13:01:59 +03:00
parent da24ed8ba7
commit 372781b956
15 changed files with 527 additions and 1158 deletions
+117 -26
View File
@@ -1,11 +1,13 @@
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
@@ -35,7 +37,20 @@ class RemnawaveApiClient:
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),
@@ -83,11 +98,24 @@ class RemnawaveApiClient:
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)
@@ -100,10 +128,24 @@ class RemnawaveApiClient:
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)
@@ -116,6 +158,8 @@ class RemnawaveApiClient:
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",
@@ -126,7 +170,8 @@ class RemnawaveApiClient:
body: dict[str, Any]
if UUID_RE.match(identifier):
body = {"uuid": identifier}
body = ({"id": await self._remote_id(identifier)}
if self._api_version == 3 else {"uuid": identifier})
elif identifier.isdigit():
body = {"id": int(identifier)}
else:
@@ -146,22 +191,28 @@ class RemnawaveApiClient:
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={"userUuid": user_uuid, "hwid": hwid},
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={"userUuid": user_uuid},
json=user_field,
)
return HwidDevicesResponse.model_validate(payload)
@@ -173,28 +224,12 @@ class RemnawaveApiClient:
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
) -> Any:
try:
response = await self._client.request(
method=method,
url=path.lstrip("/"),
params=params,
json=json,
)
except httpx.TimeoutException as exc:
raise RemnawaveApiError(
status_code=0,
message=f"Remnawave API timeout: {exc}",
) from exc
except httpx.NetworkError as exc:
raise RemnawaveApiError(
status_code=0,
message=f"Remnawave API network error: {exc}",
) from exc
except httpx.HTTPError as exc:
raise RemnawaveApiError(
status_code=0,
message=f"Remnawave API HTTP error: {exc}",
) from exc
response = await self._client.request(
method=method,
url=path.lstrip("/"),
params=params,
json=json,
)
if response.is_error:
raise self._build_error(response)
@@ -204,7 +239,63 @@ class RemnawaveApiClient:
except ValueError:
return response.text
return self.unwrap_payload(payload)
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: