prc-client 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prc/__init__.py +13 -0
- prc/base_client.py +263 -0
- prc/command.py +163 -0
- prc/events/__init__.py +4 -0
- prc/events/decorators.py +56 -0
- prc/events/integrations/__init__.py +5 -0
- prc/events/integrations/fastapi.py +24 -0
- prc/events/integrations/quart.py +25 -0
- prc/events/integrations/starlette.py +29 -0
- prc/events/models.py +138 -0
- prc/events/router.py +294 -0
- prc/exceptions.py +73 -0
- prc/policy.py +91 -0
- prc/users.py +57 -0
- prc/utils.py +107 -0
- prc/v1/__init__.py +5 -0
- prc/v1/client.py +61 -0
- prc/v1/fetch.py +119 -0
- prc/v1/models.py +263 -0
- prc/v1/send_command.py +26 -0
- prc/v2/__init__.py +5 -0
- prc/v2/client.py +262 -0
- prc/v2/models.py +502 -0
- prc/v2/send_command.py +31 -0
- prc/v2/server.py +105 -0
- prc_client-1.1.0.dist-info/METADATA +221 -0
- prc_client-1.1.0.dist-info/RECORD +29 -0
- prc_client-1.1.0.dist-info/WHEEL +4 -0
- prc_client-1.1.0.dist-info/licenses/LICENSE +21 -0
prc/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""`prc-client`: A flexible, feature-rich Python API client for the ER:LC private server API."""
|
|
2
|
+
|
|
3
|
+
from . import v1, v2, utils, exceptions
|
|
4
|
+
from .users import FullUser, UsernameUser, IdUser
|
|
5
|
+
from .command import cmd
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"v1", "v2", "utils", "exceptions",
|
|
9
|
+
"FullUser", "UsernameUser", "IdUser", "cmd"
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
prc/base_client.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
from typing import Literal, Self, cast, TYPE_CHECKING
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
import asyncio
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .policy import CommandPolicy
|
|
9
|
+
from .v2.models import Endpoint as V2Endpoint
|
|
10
|
+
from .v1.models import Endpoint as V1Endpoint
|
|
11
|
+
from .exceptions import ApiError, RateLimited, DeserializationError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .v2.client import AsyncClient as V2AsyncClient, Client as V2Client
|
|
15
|
+
from .v1.client import AsyncClient as V1AsyncClient, Client as V1Client
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
type ClientType = "V2AsyncClient | V2Client | V1AsyncClient | V1Client"
|
|
20
|
+
type HTTPXClient = httpx.Client | httpx.AsyncClient
|
|
21
|
+
type EndpointType = V1Endpoint | V2Endpoint
|
|
22
|
+
|
|
23
|
+
def create_async_client(server_key: str, **kwargs) -> httpx.AsyncClient:
|
|
24
|
+
logger.debug("Creating new async HTTPX client.")
|
|
25
|
+
return httpx.AsyncClient(
|
|
26
|
+
base_url="https://api.erlc.gg/",
|
|
27
|
+
headers={"server-key": server_key},
|
|
28
|
+
**kwargs
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def create_sync_client(server_key: str, **kwargs) -> httpx.Client:
|
|
32
|
+
logger.debug("Creating new sync HTTPX client.")
|
|
33
|
+
return httpx.Client(
|
|
34
|
+
base_url="https://api.erlc.gg/",
|
|
35
|
+
headers={"server-key": server_key},
|
|
36
|
+
**kwargs
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
class _AsyncContext:
|
|
40
|
+
server_key: str
|
|
41
|
+
connection: HTTPXClient | None
|
|
42
|
+
|
|
43
|
+
async def __aenter__(self) -> Self:
|
|
44
|
+
self.connection = create_async_client(self.server_key)
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
48
|
+
if self.connection and not self.connection.is_closed:
|
|
49
|
+
# use cast here because the context manager ensures AsyncClient
|
|
50
|
+
await cast(httpx.AsyncClient, self.connection).aclose()
|
|
51
|
+
self.connection = None
|
|
52
|
+
self.closed = True
|
|
53
|
+
|
|
54
|
+
class _SyncContext:
|
|
55
|
+
server_key: str
|
|
56
|
+
connection: HTTPXClient | None
|
|
57
|
+
|
|
58
|
+
def __enter__(self) -> Self:
|
|
59
|
+
self.connection = create_sync_client(self.server_key)
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def __exit__(self, exc_type, exc, tb):
|
|
63
|
+
if self.connection and not self.connection.is_closed:
|
|
64
|
+
# use cast here because the context manager ensures Client
|
|
65
|
+
cast(httpx.Client, self.connection).close()
|
|
66
|
+
self.connection = None
|
|
67
|
+
self.closed = True
|
|
68
|
+
|
|
69
|
+
class _BaseApiClient(_SyncContext, _AsyncContext):
|
|
70
|
+
def __init__(self,
|
|
71
|
+
server_key: str,
|
|
72
|
+
*,
|
|
73
|
+
policy: CommandPolicy | None = None,
|
|
74
|
+
wait_for_rate_limit: bool = True,
|
|
75
|
+
connection: HTTPXClient | None = None
|
|
76
|
+
) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
server_key: `str`
|
|
81
|
+
The private server API key.
|
|
82
|
+
policy: `CommandPolicy` | `None` (optional)
|
|
83
|
+
The optional command policy to use to validate commands. Raises `CommandPolicyViolation` on violations.
|
|
84
|
+
rate_limit_config: `RateLimitConfig` (optional)
|
|
85
|
+
The rate limit configuration for this client. Defaults to safe values.
|
|
86
|
+
connection: `HTTPXClient` | `None` (optional)
|
|
87
|
+
An existing HTTPX client to use. If not provided, a new one will be created.
|
|
88
|
+
"""
|
|
89
|
+
# prevent circular import
|
|
90
|
+
from .v2.client import AsyncClient as V2AsyncClient
|
|
91
|
+
from .v1.client import AsyncClient as V1AsyncClient
|
|
92
|
+
|
|
93
|
+
self.server_key: str = server_key
|
|
94
|
+
self.policy = policy
|
|
95
|
+
self.wait_for_rate_limit = wait_for_rate_limit
|
|
96
|
+
self.connection: HTTPXClient | None = connection
|
|
97
|
+
self.closed: bool = False
|
|
98
|
+
self.is_async: bool = issubclass(type(self), (V2AsyncClient, V1AsyncClient))
|
|
99
|
+
|
|
100
|
+
# rate limit tracking attributes, updated on each request based on response headers
|
|
101
|
+
self.post_expiration: int = int(time.time())
|
|
102
|
+
self.get_remaining: int = 0
|
|
103
|
+
self.get_expiration: int = int(time.time())
|
|
104
|
+
|
|
105
|
+
if self.is_async:
|
|
106
|
+
self.lock = asyncio.Lock()
|
|
107
|
+
else:
|
|
108
|
+
self.lock = threading.Lock()
|
|
109
|
+
|
|
110
|
+
def _raise_for_status(self, resp: httpx.Response):
|
|
111
|
+
body = resp.json()
|
|
112
|
+
if resp.status_code == 429:
|
|
113
|
+
logger.error("Currently being rate-limited by PRC.", extra={"body": body})
|
|
114
|
+
try:
|
|
115
|
+
raise RateLimited.from_dict(body)
|
|
116
|
+
except DeserializationError:
|
|
117
|
+
raise RateLimited(
|
|
118
|
+
code=429,
|
|
119
|
+
message="API call failed (rate limited by PRC - unknown refresh_after).",
|
|
120
|
+
retry_after=0.0
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if resp.status_code != 200:
|
|
124
|
+
try:
|
|
125
|
+
logger.error("API call failed with non-200 status code.", extra={"body": body})
|
|
126
|
+
raise ApiError.from_dict(body)
|
|
127
|
+
except DeserializationError:
|
|
128
|
+
logger.error(
|
|
129
|
+
"API call failed with non-200 status code %s.",
|
|
130
|
+
resp.status_code,
|
|
131
|
+
extra={"code": resp.status_code}
|
|
132
|
+
)
|
|
133
|
+
raise ApiError(code=resp.status_code, message="API call failed (status not 200).")
|
|
134
|
+
|
|
135
|
+
def _update_ratelimit(self, resp: httpx.Response):
|
|
136
|
+
headers = resp.headers
|
|
137
|
+
|
|
138
|
+
limit_remaining = headers.get("x-ratelimit-remaining")
|
|
139
|
+
limit_expiration = headers.get("x-ratelimit-reset")
|
|
140
|
+
|
|
141
|
+
if resp.request.method == "GET":
|
|
142
|
+
self.get_remaining = int(limit_remaining) or self.get_remaining
|
|
143
|
+
self.get_expiration = int(limit_expiration) or self.get_expiration
|
|
144
|
+
else:
|
|
145
|
+
self.post_expiration = int(limit_expiration) or self.post_expiration
|
|
146
|
+
|
|
147
|
+
logger.debug("Updated rate limit info.", extra={
|
|
148
|
+
"get_remaining": self.get_remaining,
|
|
149
|
+
"get_expiration": self.get_expiration,
|
|
150
|
+
"post_expiration": self.post_expiration
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
def _get_method(self, endpoint: EndpointType) -> Literal["GET", "POST"]:
|
|
154
|
+
if isinstance(endpoint, V2Endpoint):
|
|
155
|
+
return "GET" if endpoint == V2Endpoint.v2_server else "POST"
|
|
156
|
+
else:
|
|
157
|
+
return "POST" if endpoint == V1Endpoint.command else "GET"
|
|
158
|
+
|
|
159
|
+
async def _send_async_request(self, endpoint: EndpointType, **kwargs) -> httpx.Response:
|
|
160
|
+
if self.connection is None and self.closed:
|
|
161
|
+
logger.error("Attempted to send async request with closed connection.")
|
|
162
|
+
raise RuntimeError("Unable to make request as this connection is closed.")
|
|
163
|
+
if self.connection is None:
|
|
164
|
+
logger.debug("No existing connection found; creating new async client for request.")
|
|
165
|
+
self.connection = create_async_client(self.server_key)
|
|
166
|
+
if not isinstance(self.connection, httpx.AsyncClient):
|
|
167
|
+
logger.error("Async request attempted with non-async client.")
|
|
168
|
+
raise RuntimeError("Cannot send async request; connection is not an async client.")
|
|
169
|
+
|
|
170
|
+
method = self._get_method(endpoint)
|
|
171
|
+
if self.wait_for_rate_limit:
|
|
172
|
+
async with cast(asyncio.Lock, self.lock):
|
|
173
|
+
if method == "GET":
|
|
174
|
+
wait_for = self._get_wait_time()
|
|
175
|
+
else:
|
|
176
|
+
wait_for = self._post_wait_time()
|
|
177
|
+
|
|
178
|
+
if wait_for > 0:
|
|
179
|
+
logger.debug("Waiting for %s seconds due to rate limit before sending async request.", wait_for)
|
|
180
|
+
await asyncio.sleep(wait_for)
|
|
181
|
+
logger.debug("Rate limit waiting finished.")
|
|
182
|
+
|
|
183
|
+
logger.debug("Sending async request to endpoint %s.", endpoint.value)
|
|
184
|
+
resp = await self.connection.request(method, endpoint.value, **kwargs)
|
|
185
|
+
self._update_ratelimit(resp)
|
|
186
|
+
self._raise_for_status(resp)
|
|
187
|
+
|
|
188
|
+
return resp
|
|
189
|
+
|
|
190
|
+
logger.debug("Sending async request to endpoint %s.", endpoint.value)
|
|
191
|
+
resp = await self.connection.request(method, endpoint.value, **kwargs)
|
|
192
|
+
self._update_ratelimit(resp)
|
|
193
|
+
self._raise_for_status(resp)
|
|
194
|
+
|
|
195
|
+
return resp
|
|
196
|
+
|
|
197
|
+
def _send_sync_request(self, endpoint: EndpointType, **kwargs) -> httpx.Response:
|
|
198
|
+
if self.connection is None and self.closed:
|
|
199
|
+
logger.error("Attempted to send sync request with closed connection.")
|
|
200
|
+
raise RuntimeError("Unable to make request as this connection is closed.")
|
|
201
|
+
if self.connection is None:
|
|
202
|
+
logger.debug("No existing connection found; creating new sync client for request.")
|
|
203
|
+
self.connection = create_sync_client(self.server_key)
|
|
204
|
+
if not isinstance(self.connection, httpx.Client):
|
|
205
|
+
logger.error("Sync request attempted with non-sync client.")
|
|
206
|
+
raise RuntimeError("Cannot send sync request; connection is not a sync client.")
|
|
207
|
+
|
|
208
|
+
method = self._get_method(endpoint)
|
|
209
|
+
if self.wait_for_rate_limit:
|
|
210
|
+
with cast(threading.Lock, self.lock):
|
|
211
|
+
if method == "GET":
|
|
212
|
+
wait_for = self._get_wait_time()
|
|
213
|
+
else:
|
|
214
|
+
wait_for = self._post_wait_time()
|
|
215
|
+
|
|
216
|
+
if wait_for > 0:
|
|
217
|
+
logger.debug("Waiting for %s seconds due to rate limit before sending sync request.", wait_for)
|
|
218
|
+
time.sleep(wait_for)
|
|
219
|
+
logger.debug("Rate limit waiting finished.")
|
|
220
|
+
|
|
221
|
+
logger.debug("Sending sync request to endpoint %s.", endpoint.value)
|
|
222
|
+
resp = self.connection.request(method, endpoint.value, **kwargs)
|
|
223
|
+
self._update_ratelimit(resp)
|
|
224
|
+
self._raise_for_status(resp)
|
|
225
|
+
|
|
226
|
+
return resp
|
|
227
|
+
|
|
228
|
+
logger.debug("Sending sync request to endpoint %s.", endpoint.value)
|
|
229
|
+
resp = self.connection.request(method, endpoint.value, **kwargs)
|
|
230
|
+
self._update_ratelimit(resp)
|
|
231
|
+
self._raise_for_status(resp)
|
|
232
|
+
|
|
233
|
+
return resp
|
|
234
|
+
|
|
235
|
+
def _get_wait_time(self) -> float:
|
|
236
|
+
if self.get_remaining > 0:
|
|
237
|
+
return 0.0
|
|
238
|
+
return max(self.get_expiration - time.time() + 1, 0)
|
|
239
|
+
|
|
240
|
+
def _post_wait_time(self) -> float:
|
|
241
|
+
return max(self.post_expiration - time.time() + 1, 0)
|
|
242
|
+
|
|
243
|
+
async def aclose(self):
|
|
244
|
+
if not isinstance(self.connection, httpx.AsyncClient):
|
|
245
|
+
logger.error("Attempted to close async connection with non-async client.")
|
|
246
|
+
raise RuntimeError("Connection is not an async client; close with .close().")
|
|
247
|
+
if self.connection and not self.connection.is_closed:
|
|
248
|
+
await self.connection.aclose()
|
|
249
|
+
|
|
250
|
+
self.connection = None
|
|
251
|
+
self.closed = True
|
|
252
|
+
logger.info("Async connection closed.")
|
|
253
|
+
|
|
254
|
+
def close(self):
|
|
255
|
+
if not isinstance(self.connection, httpx.Client):
|
|
256
|
+
logger.error("Attempted to close sync connection with non-sync client.")
|
|
257
|
+
raise RuntimeError("Connection is not an sync client; close with .aclose().")
|
|
258
|
+
if self.connection and not self.connection.is_closed:
|
|
259
|
+
self.connection.close()
|
|
260
|
+
|
|
261
|
+
self.connection = None
|
|
262
|
+
self.closed = True
|
|
263
|
+
logger.info("Sync connection closed.")
|
prc/command.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
5
|
+
from httpx import Response
|
|
6
|
+
|
|
7
|
+
from .users import FullUser, UsernameUser, IdUser
|
|
8
|
+
from .v2.models import Player as V2Player
|
|
9
|
+
from .v1.models import Player as V1Player
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .v2.client import AsyncClient as V2AsyncClient, Client as V2Client
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
def normalize_command(command: str) -> str:
|
|
17
|
+
"""Normalize a command by ensuring it starts with a colon."""
|
|
18
|
+
return command if command.startswith(":") else f":{command}"
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Command:
|
|
22
|
+
"""Represents an in-game command.
|
|
23
|
+
|
|
24
|
+
You should import and use the `cmd` instance to dynamically create `Command` objects
|
|
25
|
+
instead of instantiating this class directly:
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from prc.v2 import cmd
|
|
29
|
+
|
|
30
|
+
cmd.pm("Alice", "Hello World!") # Command(text=":pm Alice Hello World!")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Attributes
|
|
34
|
+
----------
|
|
35
|
+
text: `str`
|
|
36
|
+
The command's normalized text.
|
|
37
|
+
"""
|
|
38
|
+
text: str
|
|
39
|
+
|
|
40
|
+
dangerous_cmds: ClassVar[set[str]] = {
|
|
41
|
+
":kick", ":ban", ":wanted", ":unwanted",
|
|
42
|
+
":jail", ":unjail", ":kill", ":heal",
|
|
43
|
+
":refresh", ":respawn"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
self.text = normalize_command(self.text)
|
|
48
|
+
|
|
49
|
+
async def asend(self, client: "V2AsyncClient"):
|
|
50
|
+
"""Sends this command to the API using the provided asynchronous client."""
|
|
51
|
+
logger.info("Sending async command: '%s'", self.text)
|
|
52
|
+
return await client.send_command(self)
|
|
53
|
+
|
|
54
|
+
def send(self, client: "V2Client") -> Response:
|
|
55
|
+
"""Sends this command to the API using the provided synchronous client."""
|
|
56
|
+
logger.info("Sending sync command: '%s'", self.text)
|
|
57
|
+
return client.send_command(self)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def payload(self) -> dict[str, str]:
|
|
61
|
+
"""Returns the payload to send this command to the API."""
|
|
62
|
+
return {"command": self.text}
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def command(self) -> str:
|
|
66
|
+
"""Returns the command with leading colon (`:h`, `:kick`, etc.)"""
|
|
67
|
+
return self.text.split()[0]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def dangerous(self) -> bool:
|
|
71
|
+
"""Returns True if the command is dangerous (e.g. `:kick all`, `:ban all`), else False."""
|
|
72
|
+
return any(
|
|
73
|
+
self.text.startswith((f"{cmd} all", f"{cmd} others"))
|
|
74
|
+
for cmd in type(self).dangerous_cmds
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# define user types
|
|
78
|
+
type AnyUserType = V2Player | V1Player | FullUser | UsernameUser | IdUser | str | int
|
|
79
|
+
type UsernameUserType = V2Player | V1Player | FullUser | UsernameUser | str
|
|
80
|
+
type IdUserType = V2Player | V1Player | FullUser | IdUser | int
|
|
81
|
+
|
|
82
|
+
type CommandLike = Command | str
|
|
83
|
+
|
|
84
|
+
class _CmdFactory:
|
|
85
|
+
if TYPE_CHECKING:
|
|
86
|
+
# define in-game command methods
|
|
87
|
+
def h(self, message: str) -> Command: ...
|
|
88
|
+
def hint(self, message: str) -> Command: ...
|
|
89
|
+
def m(self, message: str) -> Command: ...
|
|
90
|
+
def message(self, message: str) -> Command: ...
|
|
91
|
+
def pm(self, player: UsernameUserType | Sequence[UsernameUserType], message: str) -> Command: ...
|
|
92
|
+
def privatemessage(self, player: UsernameUserType | Sequence[UsernameUserType], message: str) -> Command: ...
|
|
93
|
+
def kick(self, player: AnyUserType | Sequence[AnyUserType], reason: str = "") -> Command: ...
|
|
94
|
+
def ban(self, player: AnyUserType | Sequence[AnyUserType], reason: str = "") -> Command: ...
|
|
95
|
+
def unban(self, player: AnyUserType | Sequence[AnyUserType]) -> Command: ...
|
|
96
|
+
def wanted(self, player: UsernameUserType | Sequence[UsernameUserType]) -> Command: ...
|
|
97
|
+
def unwanted(self, player: UsernameUserType | Sequence[UsernameUserType]) -> Command: ...
|
|
98
|
+
def jail(self, player: UsernameUserType | Sequence[UsernameUserType]) -> Command: ...
|
|
99
|
+
def unjail(self, player: UsernameUserType | Sequence[UsernameUserType]) -> Command: ...
|
|
100
|
+
def kill(self, player: UsernameUserType | Sequence[UsernameUserType]) -> Command: ...
|
|
101
|
+
def heal(self, player: UsernameUserType | Sequence[UsernameUserType]) -> Command: ...
|
|
102
|
+
def refresh(self, player: UsernameUserType | Sequence[UsernameUserType]) -> Command: ...
|
|
103
|
+
def respawn(self, player: UsernameUserType | Sequence[UsernameUserType]) -> Command: ...
|
|
104
|
+
def tp(self, player1: UsernameUserType, player2: UsernameUserType) -> Command: ...
|
|
105
|
+
def teleport(self, player1: UsernameUserType, player2: UsernameUserType) -> Command: ...
|
|
106
|
+
def admin(self, player: AnyUserType) -> Command: ...
|
|
107
|
+
def unadmin(self, player: AnyUserType) -> Command: ...
|
|
108
|
+
def mod(self, player: AnyUserType) -> Command: ...
|
|
109
|
+
def unmod(self, player: AnyUserType) -> Command: ...
|
|
110
|
+
def helper(self, player: AnyUserType) -> Command: ...
|
|
111
|
+
def unhelper(self, player: AnyUserType) -> Command: ...
|
|
112
|
+
def log(self, message: str) -> Command: ...
|
|
113
|
+
def weather(self, weather: str) -> Command: ...
|
|
114
|
+
def time(self, time: float) -> Command: ...
|
|
115
|
+
def shutdown(self) -> Command: ...
|
|
116
|
+
|
|
117
|
+
# handle argument parsing and custom command creation
|
|
118
|
+
def __getattr__(self, name: str):
|
|
119
|
+
def call(*args, **kwargs) -> Command:
|
|
120
|
+
# parse player/user objects into username/ID representations
|
|
121
|
+
parsed: list[str] = []
|
|
122
|
+
collected_args = list(args) + list(kwargs.values())
|
|
123
|
+
|
|
124
|
+
for arg in collected_args:
|
|
125
|
+
# treat non-string sequences as a grouped player list
|
|
126
|
+
if isinstance(arg, Sequence) and not isinstance(arg, (str, bytes)):
|
|
127
|
+
items: list[str] = []
|
|
128
|
+
for item in arg:
|
|
129
|
+
if isinstance(item, (V2Player, V1Player)):
|
|
130
|
+
items.append(item.user.name)
|
|
131
|
+
elif isinstance(item, (UsernameUser, FullUser)):
|
|
132
|
+
items.append(item.name)
|
|
133
|
+
elif isinstance(item, IdUser):
|
|
134
|
+
items.append(str(item.id))
|
|
135
|
+
else:
|
|
136
|
+
items.append(str(item))
|
|
137
|
+
parsed.append(','.join(items))
|
|
138
|
+
else:
|
|
139
|
+
item = arg
|
|
140
|
+
if isinstance(item, (V2Player, V1Player)):
|
|
141
|
+
parsed.append(item.user.name)
|
|
142
|
+
elif isinstance(item, (UsernameUser, FullUser)):
|
|
143
|
+
parsed.append(item.name)
|
|
144
|
+
elif isinstance(item, IdUser):
|
|
145
|
+
parsed.append(str(item.id))
|
|
146
|
+
else:
|
|
147
|
+
parsed.append(str(item))
|
|
148
|
+
|
|
149
|
+
# join the parsed arguments
|
|
150
|
+
joined_args = ' '.join(parsed)
|
|
151
|
+
full = f"{name} {joined_args}".strip()
|
|
152
|
+
logger.debug("Created command: '%s'", full)
|
|
153
|
+
|
|
154
|
+
return Command(text=full)
|
|
155
|
+
return call
|
|
156
|
+
|
|
157
|
+
cmd: _CmdFactory = _CmdFactory()
|
|
158
|
+
"""Factory for creating in-game commands.
|
|
159
|
+
|
|
160
|
+
Used to build commands, such as `cmd.hint("Hello, World!")` or `cmd.kick(["player1", "player2"], "reason")`.
|
|
161
|
+
|
|
162
|
+
In-game commands have type hints/method stubs, but any command can be created the same way
|
|
163
|
+
and user arguments will be parsed automatically."""
|
prc/events/__init__.py
ADDED
prc/events/decorators.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
if TYPE_CHECKING:
|
|
3
|
+
from .router import Router
|
|
4
|
+
|
|
5
|
+
class _On:
|
|
6
|
+
def __init__(self, router: "Router") -> None:
|
|
7
|
+
self.router = router
|
|
8
|
+
|
|
9
|
+
def command(self, command: str, *commands: str):
|
|
10
|
+
"""Register a function to be called when a specific command is run in-game."""
|
|
11
|
+
def wrapper(func):
|
|
12
|
+
self.router._add_command(func, command)
|
|
13
|
+
for cmd in commands:
|
|
14
|
+
self.router._add_command(func, cmd)
|
|
15
|
+
return func
|
|
16
|
+
return wrapper
|
|
17
|
+
|
|
18
|
+
def any_custom_command(self):
|
|
19
|
+
"""Register a function to be called when any custom command is run in-game."""
|
|
20
|
+
def wrapper(func):
|
|
21
|
+
self.router._add_command(func, None)
|
|
22
|
+
return func
|
|
23
|
+
return wrapper
|
|
24
|
+
|
|
25
|
+
def custom_event(self, event_name: str, *event_names: str):
|
|
26
|
+
"""Register a function to be called when a custom event with the specified name is received."""
|
|
27
|
+
def wrapper(func):
|
|
28
|
+
self.router._add_function(func, event_name)
|
|
29
|
+
for name in event_names:
|
|
30
|
+
self.router._add_function(func, name)
|
|
31
|
+
return func
|
|
32
|
+
return wrapper
|
|
33
|
+
|
|
34
|
+
def emergency_start(self):
|
|
35
|
+
"""Register a function to be called when an emergency call is made in-game."""
|
|
36
|
+
def wrapper(func):
|
|
37
|
+
self.router._add_function(func, "EmergencyCallStarted")
|
|
38
|
+
return func
|
|
39
|
+
return wrapper
|
|
40
|
+
|
|
41
|
+
def probe(self):
|
|
42
|
+
"""Register a function to be called when a webhook ping is received from the PRC API.
|
|
43
|
+
|
|
44
|
+
These are occasionally sent by the API to test the validity of the webhook URL and its signature verification.
|
|
45
|
+
"""
|
|
46
|
+
def wrapper(func):
|
|
47
|
+
self.router._add_function(func, "WebhookProbe")
|
|
48
|
+
return func
|
|
49
|
+
return wrapper
|
|
50
|
+
|
|
51
|
+
def any_event(self):
|
|
52
|
+
"""Register a function to be called when any non-command event is received."""
|
|
53
|
+
def wrapper(func):
|
|
54
|
+
self.router._add_function(func, None)
|
|
55
|
+
return func
|
|
56
|
+
return wrapper
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
from ..router import Router
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from fastapi import Request, BackgroundTasks
|
|
6
|
+
except ImportError:
|
|
7
|
+
raise RuntimeError((
|
|
8
|
+
"FastAPI integration for PRC events requires the `fastapi` library. "
|
|
9
|
+
"Install the dependency with `pip install prc-client[fastapi]`."
|
|
10
|
+
))
|
|
11
|
+
|
|
12
|
+
class _FastApiIntegration:
|
|
13
|
+
def __init__(self, router: Router) -> None:
|
|
14
|
+
self.router = router
|
|
15
|
+
|
|
16
|
+
async def handle_fastapi_request(self, request: Request, background_tasks: BackgroundTasks) -> Literal[200, 400]:
|
|
17
|
+
raw_body = await request.body()
|
|
18
|
+
headers = dict(request.headers)
|
|
19
|
+
|
|
20
|
+
status, task = await self.router.prepare_request(raw_body, headers)
|
|
21
|
+
if status == 200 and task:
|
|
22
|
+
background_tasks.add_task(task)
|
|
23
|
+
|
|
24
|
+
return status
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
from ..router import Router
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from quart import Quart, request
|
|
6
|
+
except ImportError:
|
|
7
|
+
raise RuntimeError((
|
|
8
|
+
"Quart integration for PRC events requires the `quart` library. "
|
|
9
|
+
"Install the dependency with `pip install prc-client[quart]`."
|
|
10
|
+
))
|
|
11
|
+
|
|
12
|
+
class _QuartIntegration:
|
|
13
|
+
def __init__(self, router: Router) -> None:
|
|
14
|
+
self.router = router
|
|
15
|
+
|
|
16
|
+
async def handle_quart_request(self, app: Quart) -> Literal[200, 400]:
|
|
17
|
+
raw_body = await request.get_data(as_text=False)
|
|
18
|
+
raw_body = raw_body.encode() if isinstance(raw_body, str) else raw_body
|
|
19
|
+
headers = dict(request.headers)
|
|
20
|
+
|
|
21
|
+
status, task = await self.router.prepare_request(raw_body, headers)
|
|
22
|
+
if status == 200 and task:
|
|
23
|
+
app.add_background_task(task)
|
|
24
|
+
|
|
25
|
+
return status
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
from ..router import Router
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from starlette.requests import Request
|
|
6
|
+
from starlette.background import BackgroundTask
|
|
7
|
+
except ImportError:
|
|
8
|
+
raise RuntimeError((
|
|
9
|
+
"Starlette integration for PRC events requires the `starlette` library. "
|
|
10
|
+
"Install the dependency with `pip install prc-client[starlette]`."
|
|
11
|
+
))
|
|
12
|
+
|
|
13
|
+
class _StarletteIntegration:
|
|
14
|
+
def __init__(self, router: Router) -> None:
|
|
15
|
+
self.router = router
|
|
16
|
+
|
|
17
|
+
async def handle_starlette_request(
|
|
18
|
+
self, request: Request
|
|
19
|
+
) -> tuple[Literal[200, 400], BackgroundTask | None]:
|
|
20
|
+
raw_body = await request.body()
|
|
21
|
+
headers = dict(request.headers)
|
|
22
|
+
|
|
23
|
+
status, task = await self.router.prepare_request(raw_body, headers)
|
|
24
|
+
if status == 200 and task:
|
|
25
|
+
background_task = BackgroundTask(task)
|
|
26
|
+
else:
|
|
27
|
+
background_task = None
|
|
28
|
+
|
|
29
|
+
return status, background_task
|