158 lines
4.4 KiB
Python
158 lines
4.4 KiB
Python
"""Official realm health used by the native launcher dashboard."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import json
|
|
from typing import Callable, Mapping, Protocol
|
|
from urllib.request import Request, urlopen
|
|
|
|
from . import __version__
|
|
|
|
|
|
# This is the same publisher endpoint embedded in Windows launcher 1.0.0.64.
|
|
SERVER_STATUS_ENDPOINT = (
|
|
"https://pristontale.eu/api/pristontale/developer"
|
|
"?api_key=d9OlDSvworitU4ajDiH6dU6cxXApc4M0"
|
|
)
|
|
MAX_SERVER_STATUS_BYTES = 1024 * 1024
|
|
|
|
|
|
class ServerStatusError(RuntimeError):
|
|
"""Raised when the official status response cannot be interpreted safely."""
|
|
|
|
|
|
class ServerStatusResponse(Protocol):
|
|
def read(self, size: int = -1) -> bytes: ...
|
|
|
|
def __enter__(self) -> ServerStatusResponse: ...
|
|
|
|
def __exit__(
|
|
self,
|
|
exception_type: type[BaseException] | None,
|
|
exception: BaseException | None,
|
|
traceback: object | None,
|
|
) -> bool | None: ...
|
|
|
|
|
|
ServerStatusOpener = Callable[..., ServerStatusResponse]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RealmServerStatus:
|
|
name: str
|
|
online: bool | None
|
|
players_online: int | None
|
|
login_online: bool | None = None
|
|
game_online: bool | None = None
|
|
|
|
|
|
def fetch_server_status(
|
|
*,
|
|
timeout_seconds: float = 10.0,
|
|
opener: ServerStatusOpener = urlopen,
|
|
) -> tuple[RealmServerStatus, ...]:
|
|
request = Request(
|
|
SERVER_STATUS_ENDPOINT,
|
|
headers={"User-Agent": f"PT-Linux/{__version__} (+https://pristontale.eu/)"},
|
|
method="GET",
|
|
)
|
|
try:
|
|
response = opener(request, timeout=timeout_seconds)
|
|
with response:
|
|
payload = response.read(MAX_SERVER_STATUS_BYTES + 1)
|
|
except OSError as exc:
|
|
raise ServerStatusError("official server status is unavailable") from exc
|
|
if len(payload) > MAX_SERVER_STATUS_BYTES:
|
|
raise ServerStatusError("official server status exceeds the 1 MiB limit")
|
|
return parse_server_status(payload)
|
|
|
|
|
|
def parse_server_status(payload: bytes) -> tuple[RealmServerStatus, ...]:
|
|
try:
|
|
value = json.loads(payload.decode("utf-8-sig"))
|
|
except (UnicodeError, json.JSONDecodeError) as exc:
|
|
raise ServerStatusError("official server status is not valid JSON") from exc
|
|
# Historical versions returned an array containing the status object. The
|
|
# current endpoint returns that object directly.
|
|
if isinstance(value, list) and value:
|
|
value = value[0]
|
|
if not isinstance(value, dict):
|
|
raise ServerStatusError("official server status is not a realm object")
|
|
|
|
statuses: list[RealmServerStatus] = []
|
|
for raw_name, raw_components in value.items():
|
|
if not isinstance(raw_name, str) or raw_name.casefold() == "website":
|
|
continue
|
|
name = raw_name.strip()
|
|
if not name or len(name) > 64 or not isinstance(raw_components, Mapping):
|
|
continue
|
|
status = _parse_realm(name, raw_components)
|
|
if status is not None:
|
|
statuses.append(status)
|
|
if not statuses:
|
|
raise ServerStatusError("official server status contains no realms")
|
|
return tuple(statuses)
|
|
|
|
|
|
def _parse_realm(
|
|
name: str,
|
|
components: Mapping[object, object],
|
|
) -> RealmServerStatus | None:
|
|
service_states: list[bool] = []
|
|
login_online: bool | None = None
|
|
game_online: bool | None = None
|
|
players_online: int | None = None
|
|
for raw_key, raw_value in components.items():
|
|
if not isinstance(raw_key, str):
|
|
continue
|
|
key = raw_key.casefold()
|
|
if key == "users.online":
|
|
players_online = _non_negative_integer(raw_value)
|
|
continue
|
|
if not key.startswith("server."):
|
|
continue
|
|
state = _online_state(raw_value)
|
|
if state is None:
|
|
continue
|
|
service_states.append(state)
|
|
if "login" in key:
|
|
login_online = state
|
|
elif "game" in key:
|
|
game_online = state
|
|
if not service_states and players_online is None:
|
|
return None
|
|
online = all(service_states) if service_states else None
|
|
display_name = name[0].upper() + name[1:]
|
|
return RealmServerStatus(
|
|
name=display_name,
|
|
online=online,
|
|
players_online=players_online,
|
|
login_online=login_online,
|
|
game_online=game_online,
|
|
)
|
|
|
|
|
|
def _online_state(value: object) -> bool | None:
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, (int, float)):
|
|
return value > 0
|
|
if isinstance(value, str):
|
|
text = value.strip().casefold()
|
|
if text in {"online", "up", "true", "1"}:
|
|
return True
|
|
if text in {"offline", "down", "false", "0"}:
|
|
return False
|
|
return None
|
|
|
|
|
|
def _non_negative_integer(value: object) -> int | None:
|
|
if isinstance(value, bool):
|
|
return None
|
|
if isinstance(value, int) and value >= 0:
|
|
return value
|
|
if isinstance(value, str) and value.isascii() and value.isdecimal():
|
|
return int(value)
|
|
return None
|