Genesis commit
This commit is contained in:
commit
a2a2bcb206
6 changed files with 4167 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.env
|
||||
venv
|
||||
__pycache__
|
||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
aiohappyeyeballs==2.6.1
|
||||
aiohttp==3.13.3
|
||||
aiosignal==1.4.0
|
||||
attrs==25.4.0
|
||||
frozenlist==1.8.0
|
||||
idna==3.11
|
||||
multidict==6.7.1
|
||||
propcache==0.4.1
|
||||
yarl==1.23.0
|
||||
3761
src/api.json
Normal file
3761
src/api.json
Normal file
File diff suppressed because it is too large
Load diff
242
src/main copy.py
Normal file
242
src/main copy.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import time
|
||||
from re import compile
|
||||
from typing import Any, AsyncGenerator, Callable
|
||||
|
||||
import aiohttp
|
||||
|
||||
|
||||
class Minecraft:
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
*,
|
||||
server_id: str | None = None,
|
||||
server_name: str | None = None,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
poll_interval: float = 1.0,
|
||||
timeout: float = 15.0,
|
||||
verify_ssl: bool = True,
|
||||
):
|
||||
if not host:
|
||||
raise ValueError("host is required")
|
||||
if not client_id or not client_secret:
|
||||
raise ValueError("client_id and client_secret are required")
|
||||
if not server_id and not server_name:
|
||||
raise ValueError("server_id or server_name is required")
|
||||
|
||||
self._base_url = host.rstrip("/")
|
||||
self._client_id = client_id
|
||||
self._client_secret = client_secret
|
||||
self._server_id = server_id
|
||||
self._server_name = server_name
|
||||
self._poll_interval = poll_interval
|
||||
self._ssl = None if verify_ssl else False
|
||||
|
||||
self._session = session or aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
)
|
||||
self._own_session = session is None
|
||||
self._token: str | None = None
|
||||
self._token_expires_at = 0.0
|
||||
self._token_lock = asyncio.Lock()
|
||||
|
||||
self._hooks: list[Callable] = []
|
||||
|
||||
async def close(self):
|
||||
if self._own_session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
async def command(self, command: str) -> str:
|
||||
"Runs a command on the Server console."
|
||||
if not command:
|
||||
raise ValueError("command is required")
|
||||
await self._ensure_server_id()
|
||||
await self._request(
|
||||
"POST",
|
||||
f"/api/servers/{self._server_id}/console",
|
||||
json_body=command,
|
||||
)
|
||||
return ""
|
||||
|
||||
async def logs_stream(self) -> AsyncGenerator[str, Any]:
|
||||
"Streams the Server logs as a generator of strings."
|
||||
await self._ensure_server_id()
|
||||
# Start from "now" to avoid dumping the full backlog.
|
||||
last_epoch = int(time.time() * 1_000_000)
|
||||
|
||||
while True:
|
||||
epoch, lines = await self._fetch_logs(time_param=last_epoch)
|
||||
if epoch is not None and epoch > last_epoch:
|
||||
last_epoch = epoch
|
||||
for line in lines:
|
||||
yield line
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
|
||||
async def logs_tail(self, back: int = 10) -> AsyncGenerator[str, Any]:
|
||||
"Returns the last `back` lines of the Server logs as a generator of strings."
|
||||
if back <= 0:
|
||||
return
|
||||
yield # pragma: no cover - keeps this as an async generator
|
||||
await self._ensure_server_id()
|
||||
_, lines = await self._fetch_logs(time_param=0)
|
||||
for line in lines[-back:]:
|
||||
yield line
|
||||
|
||||
async def logs_tails(self, back: int = 10) -> AsyncGenerator[str, Any]:
|
||||
"Alias for logs_tail (kept for backward compatibility)."
|
||||
async for line in self.logs_tail(back=back):
|
||||
yield line
|
||||
|
||||
def onConsoleLog(self, pattern: str | None = None) -> Callable:
|
||||
"Registers a callback function to be called when a line matching the given pattern is logged."
|
||||
|
||||
def wrapper(func: Callable):
|
||||
compiled = compile(pattern) if pattern else None
|
||||
|
||||
async def callback(line: str):
|
||||
if compiled is None:
|
||||
func(line)
|
||||
return
|
||||
|
||||
match = compiled.fullmatch(line)
|
||||
if match is None:
|
||||
return
|
||||
|
||||
groups = match.groups()
|
||||
if groups:
|
||||
await func(*groups)
|
||||
else:
|
||||
await func(line)
|
||||
|
||||
self._hooks.append(callback)
|
||||
return func
|
||||
|
||||
return wrapper
|
||||
|
||||
async def loop(self):
|
||||
async for line in self.logs_stream():
|
||||
for hook in self._hooks:
|
||||
await hook(line)
|
||||
|
||||
async def _ensure_token(self) -> None:
|
||||
async with self._token_lock:
|
||||
if self._token and time.monotonic() < (self._token_expires_at - 30):
|
||||
return
|
||||
url = f"{self._base_url}/oauth2/token"
|
||||
payload = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self._client_id,
|
||||
"client_secret": self._client_secret,
|
||||
}
|
||||
async with self._session.post(url, data=payload, ssl=self._ssl) as resp:
|
||||
data = await self._read_json(resp)
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise RuntimeError("Failed to obtain access token from PufferPanel")
|
||||
expires_in = int(data.get("expires_in", 3600))
|
||||
self._token = token
|
||||
self._token_expires_at = time.monotonic() + max(expires_in, 0)
|
||||
|
||||
async def _ensure_server_id(self) -> None:
|
||||
if self._server_id:
|
||||
return
|
||||
if not self._server_name:
|
||||
raise RuntimeError("server_id is not set and server_name is missing")
|
||||
data = await self._request_json("GET", "/api/servers")
|
||||
servers = data.get("servers", [])
|
||||
for server in servers:
|
||||
if server.get("name") == self._server_name:
|
||||
self._server_id = server.get("id")
|
||||
break
|
||||
if not self._server_id:
|
||||
raise RuntimeError(f"Server named {self._server_name} was not found")
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
json_body: Any | None = None,
|
||||
) -> None:
|
||||
await self._ensure_token()
|
||||
url = f"{self._base_url}{path}"
|
||||
headers = {"Authorization": f"Bearer {self._token}"}
|
||||
async with self._session.request(
|
||||
method,
|
||||
url,
|
||||
params=params,
|
||||
json=json_body,
|
||||
headers=headers,
|
||||
ssl=self._ssl,
|
||||
) as resp:
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
raise RuntimeError(
|
||||
f"PufferPanel API error {resp.status} for {method} {path}: {body}"
|
||||
)
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
json_body: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
await self._ensure_token()
|
||||
url = f"{self._base_url}{path}"
|
||||
headers = {"Authorization": f"Bearer {self._token}"}
|
||||
async with self._session.request(
|
||||
method,
|
||||
url,
|
||||
params=params,
|
||||
json=json_body,
|
||||
headers=headers,
|
||||
ssl=self._ssl,
|
||||
) as resp:
|
||||
return await self._read_json(resp)
|
||||
|
||||
async def _read_json(self, resp: aiohttp.ClientResponse) -> dict[str, Any]:
|
||||
text = await resp.text()
|
||||
if resp.status >= 400:
|
||||
raise RuntimeError(
|
||||
f"PufferPanel API error {resp.status} for {resp.request_info.method} "
|
||||
f"{resp.request_info.url}: {text}"
|
||||
)
|
||||
if not text:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"Invalid JSON response: {text}") from exc
|
||||
|
||||
async def _fetch_logs(self, *, time_param: int) -> tuple[int | None, list[str]]:
|
||||
data = await self._request_json(
|
||||
"GET",
|
||||
f"/api/servers/{self._server_id}/console",
|
||||
params={"time": time_param},
|
||||
)
|
||||
epoch = data.get("epoch")
|
||||
raw_logs = data.get("logs") or ""
|
||||
decoded = self._decode_logs(raw_logs)
|
||||
lines = decoded.splitlines() if decoded else []
|
||||
return epoch, lines
|
||||
|
||||
def _decode_logs(self, raw_logs: str) -> str:
|
||||
if not raw_logs:
|
||||
return ""
|
||||
try:
|
||||
decoded = base64.b64decode(raw_logs)
|
||||
except (binascii.Error, ValueError):
|
||||
return raw_logs
|
||||
try:
|
||||
return decoded.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return decoded.decode(errors="replace")
|
||||
90
src/main.py
Normal file
90
src/main.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import binascii
|
||||
from asyncio import Lock
|
||||
from base64 import b64decode
|
||||
from time import monotonic, time
|
||||
from typing import Any, Callable, Literal
|
||||
|
||||
from aiohttp import ClientSession
|
||||
|
||||
|
||||
class Minecraft:
|
||||
def __init__(self, host: str, client_id: str, client_secret: str, server_id: str):
|
||||
self.host = host.rstrip("/")
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.server_id = server_id
|
||||
|
||||
self.session = ClientSession()
|
||||
|
||||
self.token: str | None = None
|
||||
self.token_expiry: float = 0.0
|
||||
self.token_lock = Lock()
|
||||
|
||||
self.hooks: list[Callable] = []
|
||||
|
||||
async def close(self):
|
||||
if not self.session.closed:
|
||||
await self.session.close()
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: Literal["GET", "POST"],
|
||||
path: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
body: Any | None = None,
|
||||
) -> Any:
|
||||
async with self.token_lock:
|
||||
if self.token and monotonic() < self.token_expiry - 30:
|
||||
return
|
||||
|
||||
url = f"{self.host}/oauth2/token"
|
||||
payload = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
}
|
||||
|
||||
async with self.session.post(url, data=payload) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
else:
|
||||
raise RuntimeError("Error")
|
||||
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise RuntimeError("Error")
|
||||
|
||||
expires_in = int(data.get("expires_in", 3600))
|
||||
self.token = token
|
||||
self.token_expiry = monotonic() + expires_in
|
||||
|
||||
url = f"{self.host}{path}"
|
||||
headers = {"Authorization": f"Bearer {self.token}"}
|
||||
async with self.session.request(
|
||||
method, url, params=params, json=body, headers=headers
|
||||
) as response:
|
||||
return await response.json()
|
||||
|
||||
async def _fetch_logs(self, last_epoch: int) -> AsyncGenerator[str, None]:
|
||||
data = await self._request(
|
||||
"GET", f"/api/servers/{self.server_id}/console", params={"time": last_epoch}
|
||||
)
|
||||
epoch = data.get("epoch")
|
||||
raw_logs = data.get("logs", "")
|
||||
try:
|
||||
...
|
||||
except (binascii.Error, ValueError):
|
||||
decoded = raw_logs
|
||||
|
||||
async def command(self, command: str) -> str:
|
||||
await self._request(
|
||||
"POST", f"/api/servers/{self.server_id}/console", body=command
|
||||
)
|
||||
|
||||
return "" # TODO: return command output
|
||||
|
||||
async def logs_stream(self) -> AsyncGenerator[str, None]:
|
||||
last_epoch = int(time() * 1_000_000)
|
||||
|
||||
while True:
|
||||
...
|
||||
62
test.py
Normal file
62
test.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_env_file(path: Path) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
root = Path(__file__).resolve().parent
|
||||
load_env_file(root / ".env")
|
||||
|
||||
sys.path.insert(0, str(root / "src"))
|
||||
from src.main import Minecraft
|
||||
|
||||
host = os.environ.get("SERVER_HOST")
|
||||
client_id = os.environ.get("PUFFERPANEL_CLIENT_ID")
|
||||
client_secret = os.environ.get("PUFFERPANEL_CLIENT_SECRET")
|
||||
if not host or not client_id or not client_secret:
|
||||
raise RuntimeError(
|
||||
"Missing SERVER_HOST / PUFFERPANEL_CLIENT_ID / PUFFERPANEL_CLIENT_SECRET"
|
||||
)
|
||||
|
||||
mc = Minecraft(
|
||||
host=host,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
server_name="Retards Server",
|
||||
poll_interval=0.5,
|
||||
)
|
||||
|
||||
try:
|
||||
await mc.command("say hi from test.py")
|
||||
|
||||
tail = [line async for line in mc.logs_tail(5)]
|
||||
print("tail (last 5 lines):")
|
||||
for line in tail:
|
||||
print(line)
|
||||
|
||||
print("streaming next log line...")
|
||||
async for line in mc.logs_stream():
|
||||
print("stream:", line)
|
||||
break
|
||||
finally:
|
||||
await mc.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue