nestra-py 1.2.2__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.
- nestra/__init__.py +77 -0
- nestra/_ws.py +322 -0
- nestra/commands.py +247 -0
- nestra/embed.py +326 -0
- nestra/gateway.py +593 -0
- nestra/rest.py +1245 -0
- nestra/types.py +402 -0
- nestra_py-1.2.2.dist-info/METADATA +326 -0
- nestra_py-1.2.2.dist-info/RECORD +11 -0
- nestra_py-1.2.2.dist-info/WHEEL +5 -0
- nestra_py-1.2.2.dist-info/top_level.txt +1 -0
nestra/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Nestra-PY - oficjalne SDK Pythona dla API i Gateway Nestra.
|
|
2
|
+
|
|
3
|
+
Pakiet nie ma zaleznosci zewnetrznych: REST korzysta z urllib, Gateway z
|
|
4
|
+
asyncio streams i wlasnej implementacji WebSocket.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .commands import Command, CommandContext, CommandHandler, ParsedCommand, parse_command
|
|
10
|
+
from .embed import (
|
|
11
|
+
COLORS,
|
|
12
|
+
EMBED_MAX_COUNT,
|
|
13
|
+
EMBED_MAX_TOTAL_CHARS,
|
|
14
|
+
Embed,
|
|
15
|
+
EmbedBuilder,
|
|
16
|
+
parse_color,
|
|
17
|
+
total_characters,
|
|
18
|
+
validate_embeds,
|
|
19
|
+
)
|
|
20
|
+
from .gateway import GatewayClient, GatewayError, GatewayFatalError, ReconnectBackoff
|
|
21
|
+
from .rest import (
|
|
22
|
+
DEFAULT_API_URL,
|
|
23
|
+
NestraAPIError,
|
|
24
|
+
NestraREST,
|
|
25
|
+
RateLimitBucket,
|
|
26
|
+
RateLimitQueue,
|
|
27
|
+
)
|
|
28
|
+
from .types import (
|
|
29
|
+
Attachment,
|
|
30
|
+
Channel,
|
|
31
|
+
Guild,
|
|
32
|
+
GuildDetail,
|
|
33
|
+
Member,
|
|
34
|
+
Message,
|
|
35
|
+
MessagePage,
|
|
36
|
+
Role,
|
|
37
|
+
User,
|
|
38
|
+
Webhook,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__version__ = "1.2.2"
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"Attachment",
|
|
45
|
+
"COLORS",
|
|
46
|
+
"Channel",
|
|
47
|
+
"Command",
|
|
48
|
+
"CommandContext",
|
|
49
|
+
"CommandHandler",
|
|
50
|
+
"DEFAULT_API_URL",
|
|
51
|
+
"EMBED_MAX_COUNT",
|
|
52
|
+
"EMBED_MAX_TOTAL_CHARS",
|
|
53
|
+
"Embed",
|
|
54
|
+
"EmbedBuilder",
|
|
55
|
+
"GatewayClient",
|
|
56
|
+
"GatewayError",
|
|
57
|
+
"GatewayFatalError",
|
|
58
|
+
"Guild",
|
|
59
|
+
"GuildDetail",
|
|
60
|
+
"Member",
|
|
61
|
+
"Message",
|
|
62
|
+
"MessagePage",
|
|
63
|
+
"NestraAPIError",
|
|
64
|
+
"NestraREST",
|
|
65
|
+
"ParsedCommand",
|
|
66
|
+
"RateLimitBucket",
|
|
67
|
+
"RateLimitQueue",
|
|
68
|
+
"ReconnectBackoff",
|
|
69
|
+
"Role",
|
|
70
|
+
"User",
|
|
71
|
+
"Webhook",
|
|
72
|
+
"__version__",
|
|
73
|
+
"parse_color",
|
|
74
|
+
"parse_command",
|
|
75
|
+
"total_characters",
|
|
76
|
+
"validate_embeds",
|
|
77
|
+
]
|
nestra/_ws.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Minimalny klient WebSocket (RFC 6455) na asyncio streams.
|
|
2
|
+
|
|
3
|
+
Obsluguje tekstowe ramki, ping/pong, close, fragmentacje i TLS. Celowo nie ma
|
|
4
|
+
zaleznosci zewnetrznych. To warstwa transportowa dla :mod:`nestra.gateway`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
import ssl
|
|
14
|
+
import struct
|
|
15
|
+
from typing import Mapping
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
19
|
+
DEFAULT_MAX_PAYLOAD = 1024 * 1024
|
|
20
|
+
|
|
21
|
+
OP_CONTINUATION = 0x0
|
|
22
|
+
OP_TEXT = 0x1
|
|
23
|
+
OP_BINARY = 0x2
|
|
24
|
+
OP_CLOSE = 0x8
|
|
25
|
+
OP_PING = 0x9
|
|
26
|
+
OP_PONG = 0xA
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WebSocketError(Exception):
|
|
30
|
+
"""Bazowy blad transportu WebSocket."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WebSocketClosed(WebSocketError):
|
|
34
|
+
"""Serwer (lub klient) zamknal polaczenie."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, code: int = 1000, reason: str = "") -> None:
|
|
37
|
+
self.code = code
|
|
38
|
+
self.reason = reason
|
|
39
|
+
super().__init__(f"WebSocket zamkniety ({code}): {reason or 'bez powodu'}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WebSocketProtocolError(WebSocketError):
|
|
43
|
+
"""Niepoprawna ramka lub naruszenie protokolu."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _apply_mask(data: bytes, mask: bytes) -> bytes:
|
|
47
|
+
return bytes(byte ^ mask[index % 4] for index, byte in enumerate(data))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def encode_frame(opcode: int, payload: bytes = b"", *, fin: bool = True, mask: bool = True) -> bytes:
|
|
51
|
+
"""Koduje pojedyncza ramke. Klient zawsze powinien uzywac mask=True."""
|
|
52
|
+
if not 0 <= opcode <= 0xF:
|
|
53
|
+
raise ValueError("Nieprawidlowy opcode")
|
|
54
|
+
first = (0x80 if fin else 0) | opcode
|
|
55
|
+
length = len(payload)
|
|
56
|
+
header = bytearray([first])
|
|
57
|
+
mask_bit = 0x80 if mask else 0
|
|
58
|
+
if length <= 125:
|
|
59
|
+
header.append(mask_bit | length)
|
|
60
|
+
elif length <= 0xFFFF:
|
|
61
|
+
header.append(mask_bit | 126)
|
|
62
|
+
header.extend(struct.pack("!H", length))
|
|
63
|
+
else:
|
|
64
|
+
header.append(mask_bit | 127)
|
|
65
|
+
header.extend(struct.pack("!Q", length))
|
|
66
|
+
if not mask:
|
|
67
|
+
return bytes(header) + payload
|
|
68
|
+
mask_key = os.urandom(4)
|
|
69
|
+
header.extend(mask_key)
|
|
70
|
+
return bytes(header) + _apply_mask(payload, mask_key)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def read_frame(
|
|
74
|
+
reader: asyncio.StreamReader,
|
|
75
|
+
*,
|
|
76
|
+
max_payload: int = DEFAULT_MAX_PAYLOAD,
|
|
77
|
+
) -> tuple[bool, int, bytes]:
|
|
78
|
+
"""Czyta jedna ramke z serwera. Serwer nie moze masowac ramek."""
|
|
79
|
+
header = await reader.readexactly(2)
|
|
80
|
+
first, second = header[0], header[1]
|
|
81
|
+
fin = bool(first & 0x80)
|
|
82
|
+
opcode = first & 0x0F
|
|
83
|
+
masked = bool(second & 0x80)
|
|
84
|
+
if masked:
|
|
85
|
+
raise WebSocketProtocolError("Serwer nie moze wysylac zamaskowanych ramek")
|
|
86
|
+
length = second & 0x7F
|
|
87
|
+
if length == 126:
|
|
88
|
+
length = struct.unpack("!H", await reader.readexactly(2))[0]
|
|
89
|
+
elif length == 127:
|
|
90
|
+
raw_length = await reader.readexactly(8)
|
|
91
|
+
if raw_length[0] & 0x80:
|
|
92
|
+
raise WebSocketProtocolError("Nieprawidlowa dlugosc ramki")
|
|
93
|
+
length = struct.unpack("!Q", raw_length)[0]
|
|
94
|
+
if length > max_payload:
|
|
95
|
+
raise WebSocketProtocolError(f"Ramka przekracza limit {max_payload} bajtow")
|
|
96
|
+
payload = await reader.readexactly(length) if length else b""
|
|
97
|
+
return fin, opcode, payload
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_close(payload: bytes) -> tuple[int, str]:
|
|
101
|
+
if len(payload) == 1:
|
|
102
|
+
return 1002, ""
|
|
103
|
+
if len(payload) >= 2:
|
|
104
|
+
code = struct.unpack("!H", payload[:2])[0]
|
|
105
|
+
try:
|
|
106
|
+
reason = payload[2:].decode("utf-8")
|
|
107
|
+
except UnicodeDecodeError:
|
|
108
|
+
reason = ""
|
|
109
|
+
return code, reason
|
|
110
|
+
return 1005, ""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class WebSocketConnection:
|
|
114
|
+
"""Polaczenie WebSocket z prostym API tekstowym."""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
reader: asyncio.StreamReader,
|
|
119
|
+
writer: asyncio.StreamWriter,
|
|
120
|
+
*,
|
|
121
|
+
max_payload: int = DEFAULT_MAX_PAYLOAD,
|
|
122
|
+
) -> None:
|
|
123
|
+
self._reader = reader
|
|
124
|
+
self._writer = writer
|
|
125
|
+
self._max_payload = max_payload
|
|
126
|
+
self._closed = False
|
|
127
|
+
self._close_code: int | None = None
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def closed(self) -> bool:
|
|
131
|
+
return self._closed
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def close_code(self) -> int | None:
|
|
135
|
+
return self._close_code
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
async def connect(
|
|
139
|
+
cls,
|
|
140
|
+
url: str,
|
|
141
|
+
*,
|
|
142
|
+
origin: str | None = None,
|
|
143
|
+
headers: Mapping[str, str] | None = None,
|
|
144
|
+
timeout: float = 15.0,
|
|
145
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
146
|
+
max_payload: int = DEFAULT_MAX_PAYLOAD,
|
|
147
|
+
) -> WebSocketConnection:
|
|
148
|
+
parsed = urlsplit(url)
|
|
149
|
+
if parsed.scheme not in {"ws", "wss"}:
|
|
150
|
+
raise ValueError("URL WebSocket musi miec schemat ws albo wss")
|
|
151
|
+
if not parsed.hostname:
|
|
152
|
+
raise ValueError("URL WebSocket musi zawierac host")
|
|
153
|
+
port = parsed.port or (443 if parsed.scheme == "wss" else 80)
|
|
154
|
+
context = ssl_context
|
|
155
|
+
if parsed.scheme == "wss" and context is None:
|
|
156
|
+
context = ssl.create_default_context()
|
|
157
|
+
connection_timeout = max(0.1, timeout)
|
|
158
|
+
reader, writer = await asyncio.wait_for(
|
|
159
|
+
asyncio.open_connection(
|
|
160
|
+
parsed.hostname,
|
|
161
|
+
port,
|
|
162
|
+
ssl=context,
|
|
163
|
+
server_hostname=parsed.hostname if context is not None else None,
|
|
164
|
+
),
|
|
165
|
+
timeout=connection_timeout,
|
|
166
|
+
)
|
|
167
|
+
try:
|
|
168
|
+
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
|
169
|
+
path = parsed.path or "/"
|
|
170
|
+
if parsed.query:
|
|
171
|
+
path = f"{path}?{parsed.query}"
|
|
172
|
+
default_port = 443 if parsed.scheme == "wss" else 80
|
|
173
|
+
host_header = parsed.hostname if port == default_port else f"{parsed.hostname}:{port}"
|
|
174
|
+
request_headers = {
|
|
175
|
+
"Host": host_header,
|
|
176
|
+
"Upgrade": "websocket",
|
|
177
|
+
"Connection": "Upgrade",
|
|
178
|
+
"Sec-WebSocket-Key": key,
|
|
179
|
+
"Sec-WebSocket-Version": "13",
|
|
180
|
+
}
|
|
181
|
+
if origin:
|
|
182
|
+
request_headers["Origin"] = origin
|
|
183
|
+
if headers:
|
|
184
|
+
request_headers.update(headers)
|
|
185
|
+
for name, value in request_headers.items():
|
|
186
|
+
if any(character in name for character in ("\r", "\n", ":")):
|
|
187
|
+
raise WebSocketProtocolError("Nieprawidlowa nazwa naglowka WebSocket")
|
|
188
|
+
if any(character in value for character in ("\r", "\n")):
|
|
189
|
+
raise WebSocketProtocolError("Nieprawidlowa wartosc naglowka WebSocket")
|
|
190
|
+
if "\r" in path or "\n" in path or " " in path:
|
|
191
|
+
raise WebSocketProtocolError("Nieprawidlowa sciezka WebSocket")
|
|
192
|
+
request = f"GET {path} HTTP/1.1\r\n"
|
|
193
|
+
request += "".join(f"{name}: {value}\r\n" for name, value in request_headers.items())
|
|
194
|
+
request += "\r\n"
|
|
195
|
+
writer.write(request.encode("latin-1"))
|
|
196
|
+
await writer.drain()
|
|
197
|
+
response = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=connection_timeout)
|
|
198
|
+
lines = response.decode("latin-1").split("\r\n")
|
|
199
|
+
status_parts = lines[0].split(" ", 2)
|
|
200
|
+
if len(status_parts) < 2 or status_parts[1] != "101":
|
|
201
|
+
raise WebSocketProtocolError(f"Serwer odrzucil handshake: {lines[0]}")
|
|
202
|
+
response_headers: dict[str, str] = {}
|
|
203
|
+
for line in lines[1:]:
|
|
204
|
+
if ":" not in line:
|
|
205
|
+
continue
|
|
206
|
+
name, value = line.split(":", 1)
|
|
207
|
+
response_headers[name.strip().lower()] = value.strip()
|
|
208
|
+
expected = base64.b64encode(hashlib.sha1((key + WS_GUID).encode("ascii")).digest()).decode("ascii")
|
|
209
|
+
if response_headers.get("sec-websocket-accept") != expected:
|
|
210
|
+
raise WebSocketProtocolError("Nieprawidlowy naglowek Sec-WebSocket-Accept")
|
|
211
|
+
return cls(reader, writer, max_payload=max_payload)
|
|
212
|
+
except BaseException:
|
|
213
|
+
writer.close()
|
|
214
|
+
try:
|
|
215
|
+
await writer.wait_closed()
|
|
216
|
+
except OSError:
|
|
217
|
+
pass
|
|
218
|
+
raise
|
|
219
|
+
|
|
220
|
+
async def send_text(self, text: str) -> None:
|
|
221
|
+
await self._send_frame(OP_TEXT, text.encode("utf-8"))
|
|
222
|
+
|
|
223
|
+
async def send_json(self, value: object) -> None:
|
|
224
|
+
import json
|
|
225
|
+
|
|
226
|
+
await self.send_text(json.dumps(value, separators=(",", ":"), ensure_ascii=False))
|
|
227
|
+
|
|
228
|
+
async def ping(self, payload: bytes = b"") -> None:
|
|
229
|
+
if len(payload) > 125:
|
|
230
|
+
raise ValueError("Ping moze miec maksymalnie 125 bajtow")
|
|
231
|
+
await self._send_frame(OP_PING, payload)
|
|
232
|
+
|
|
233
|
+
async def pong(self, payload: bytes = b"") -> None:
|
|
234
|
+
await self._send_frame(OP_PONG, payload)
|
|
235
|
+
|
|
236
|
+
async def _send_frame(self, opcode: int, payload: bytes = b"") -> None:
|
|
237
|
+
if self._closed:
|
|
238
|
+
raise WebSocketClosed(self._close_code or 1000, "Polaczenie jest zamkniete")
|
|
239
|
+
self._writer.write(encode_frame(opcode, payload, mask=True))
|
|
240
|
+
await self._writer.drain()
|
|
241
|
+
|
|
242
|
+
async def receive_text(self, *, timeout: float | None = None) -> str:
|
|
243
|
+
if timeout is None:
|
|
244
|
+
return await self._receive_text()
|
|
245
|
+
return await asyncio.wait_for(self._receive_text(), timeout=timeout)
|
|
246
|
+
|
|
247
|
+
async def _receive_text(self) -> str:
|
|
248
|
+
fragments: list[bytes] = []
|
|
249
|
+
fragmented = False
|
|
250
|
+
while True:
|
|
251
|
+
try:
|
|
252
|
+
fin, opcode, payload = await read_frame(self._reader, max_payload=self._max_payload)
|
|
253
|
+
except asyncio.IncompleteReadError as error:
|
|
254
|
+
self._closed = True
|
|
255
|
+
raise WebSocketClosed(1006, "Nieoczekiwane zamkniecie polaczenia") from error
|
|
256
|
+
except (ConnectionError, OSError) as error:
|
|
257
|
+
self._closed = True
|
|
258
|
+
raise WebSocketClosed(1006, str(error)) from error
|
|
259
|
+
|
|
260
|
+
if opcode == OP_TEXT:
|
|
261
|
+
if fragmented:
|
|
262
|
+
raise WebSocketProtocolError("Nowa ramka tekstowa w trakcie fragmentacji")
|
|
263
|
+
if not fin:
|
|
264
|
+
fragments = [payload]
|
|
265
|
+
fragmented = True
|
|
266
|
+
continue
|
|
267
|
+
return self._decode_text(payload)
|
|
268
|
+
if opcode == OP_CONTINUATION:
|
|
269
|
+
if not fragmented:
|
|
270
|
+
raise WebSocketProtocolError("Ramka kontynuacji bez poczatku wiadomosci")
|
|
271
|
+
fragments.append(payload)
|
|
272
|
+
if sum(len(part) for part in fragments) > self._max_payload:
|
|
273
|
+
raise WebSocketProtocolError("Wiadomosc przekracza limit bajtow")
|
|
274
|
+
if not fin:
|
|
275
|
+
continue
|
|
276
|
+
fragmented = False
|
|
277
|
+
return self._decode_text(b"".join(fragments))
|
|
278
|
+
if opcode == OP_BINARY:
|
|
279
|
+
await self.close(1003, "Nieobslugiwane ramki binarne")
|
|
280
|
+
raise WebSocketProtocolError("Serwer wyslal ramke binarna")
|
|
281
|
+
if opcode == OP_CLOSE:
|
|
282
|
+
code, reason = _parse_close(payload)
|
|
283
|
+
self._close_code = code
|
|
284
|
+
# Kody 1005/1006/1015 sa zarezerwowane i nie moga trafic na wire.
|
|
285
|
+
echo_code = 1000 if code in {1005, 1006, 1015} else code
|
|
286
|
+
await self._send_close(echo_code, reason)
|
|
287
|
+
self._writer.close()
|
|
288
|
+
raise WebSocketClosed(code, reason)
|
|
289
|
+
if opcode == OP_PING:
|
|
290
|
+
await self.pong(payload)
|
|
291
|
+
continue
|
|
292
|
+
if opcode == OP_PONG:
|
|
293
|
+
continue
|
|
294
|
+
raise WebSocketProtocolError(f"Nieznany opcode: {opcode}")
|
|
295
|
+
|
|
296
|
+
def _decode_text(self, payload: bytes) -> str:
|
|
297
|
+
try:
|
|
298
|
+
return payload.decode("utf-8")
|
|
299
|
+
except UnicodeDecodeError as error:
|
|
300
|
+
raise WebSocketProtocolError("Tekst nie jest poprawnym UTF-8") from error
|
|
301
|
+
|
|
302
|
+
async def _send_close(self, code: int = 1000, reason: str = "") -> None:
|
|
303
|
+
if self._closed:
|
|
304
|
+
return
|
|
305
|
+
self._closed = True
|
|
306
|
+
self._close_code = code
|
|
307
|
+
payload = struct.pack("!H", code) + reason.encode("utf-8")[:123]
|
|
308
|
+
try:
|
|
309
|
+
self._writer.write(encode_frame(OP_CLOSE, payload, mask=True))
|
|
310
|
+
await self._writer.drain()
|
|
311
|
+
except (ConnectionError, OSError):
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
async def close(self, code: int = 1000, reason: str = "") -> None:
|
|
315
|
+
if self._closed:
|
|
316
|
+
return
|
|
317
|
+
await self._send_close(code, reason)
|
|
318
|
+
self._writer.close()
|
|
319
|
+
try:
|
|
320
|
+
await self._writer.wait_closed()
|
|
321
|
+
except (ConnectionError, OSError):
|
|
322
|
+
pass
|
nestra/commands.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Komendy prefiksowe dla botow Nestra.
|
|
2
|
+
|
|
3
|
+
Przyklad::
|
|
4
|
+
|
|
5
|
+
commands = CommandHandler(prefix="!")
|
|
6
|
+
|
|
7
|
+
@commands.command("ping", description="Odpowiada pongiem")
|
|
8
|
+
async def ping(ctx: CommandContext) -> None:
|
|
9
|
+
await ctx.reply("pong")
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import inspect
|
|
15
|
+
import logging
|
|
16
|
+
import shlex
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Callable, Iterable, Sequence
|
|
19
|
+
|
|
20
|
+
from .embed import EmbedBuilder
|
|
21
|
+
from .rest import NestraREST
|
|
22
|
+
from .types import Message, User
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("nestra.commands")
|
|
25
|
+
|
|
26
|
+
CommandFunc = Callable[["CommandContext"], Any]
|
|
27
|
+
ErrorFunc = Callable[["CommandContext", Exception], Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class ParsedCommand:
|
|
32
|
+
name: str
|
|
33
|
+
args: str = ""
|
|
34
|
+
argv: list[str] | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_command(content: str, prefix: str = "!") -> ParsedCommand | None:
|
|
38
|
+
"""Parsuje ``prefix`` + nazwa + argumenty. Zwraca None dla zwyklego tekstu."""
|
|
39
|
+
if not prefix:
|
|
40
|
+
raise ValueError("Prefix nie moze byc pusty")
|
|
41
|
+
if not content or not content.startswith(prefix):
|
|
42
|
+
return None
|
|
43
|
+
rest = content[len(prefix) :].strip()
|
|
44
|
+
if not rest:
|
|
45
|
+
return None
|
|
46
|
+
parts = rest.split(None, 1)
|
|
47
|
+
name = parts[0]
|
|
48
|
+
args = parts[1].strip() if len(parts) > 1 else ""
|
|
49
|
+
try:
|
|
50
|
+
argv = shlex.split(args, posix=True) if args else []
|
|
51
|
+
except ValueError:
|
|
52
|
+
argv = args.split()
|
|
53
|
+
return ParsedCommand(name=name, args=args, argv=argv)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class Command:
|
|
58
|
+
name: str
|
|
59
|
+
func: CommandFunc
|
|
60
|
+
aliases: tuple[str, ...] = ()
|
|
61
|
+
description: str = ""
|
|
62
|
+
usage: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(slots=True)
|
|
66
|
+
class CommandContext:
|
|
67
|
+
"""Kontekst wywolania komendy przekazywany do handlera."""
|
|
68
|
+
|
|
69
|
+
message: Message
|
|
70
|
+
command: Command
|
|
71
|
+
args: str
|
|
72
|
+
argv: list[str]
|
|
73
|
+
rest: NestraREST | None = None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def author(self) -> User | None:
|
|
77
|
+
return self.message.author
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def channel_id(self) -> str:
|
|
81
|
+
return self.message.channel_id
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def guild_id(self) -> str | None:
|
|
85
|
+
return self.message.guild_id
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def author_name(self) -> str:
|
|
89
|
+
return self.author.name if self.author is not None else "nieznany"
|
|
90
|
+
|
|
91
|
+
async def reply(
|
|
92
|
+
self,
|
|
93
|
+
content: str | None = None,
|
|
94
|
+
*,
|
|
95
|
+
embeds: EmbedBuilder | dict[str, Any] | Iterable[EmbedBuilder | dict[str, Any]] | None = None,
|
|
96
|
+
reply_to: bool = True,
|
|
97
|
+
) -> Message:
|
|
98
|
+
"""Wysyla odpowiedz na kanale wiadomosci (domyslnie jako reply)."""
|
|
99
|
+
if self.rest is None:
|
|
100
|
+
raise RuntimeError("Brak klienta REST w CommandContext")
|
|
101
|
+
return await self.rest.send_message(
|
|
102
|
+
self.channel_id,
|
|
103
|
+
content,
|
|
104
|
+
embeds=embeds,
|
|
105
|
+
reply_to_id=self.message.id if reply_to else None,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
async def send(
|
|
109
|
+
self,
|
|
110
|
+
content: str | None = None,
|
|
111
|
+
*,
|
|
112
|
+
embeds: EmbedBuilder | dict[str, Any] | Iterable[EmbedBuilder | dict[str, Any]] | None = None,
|
|
113
|
+
) -> Message:
|
|
114
|
+
"""Wysyla wiadomosc bez reply."""
|
|
115
|
+
return await self.reply(content, embeds=embeds, reply_to=False)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class CommandHandler:
|
|
119
|
+
"""Rejestr komend prefiksowych i dispatcher wiadomosci."""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
prefix: str = "!",
|
|
124
|
+
*,
|
|
125
|
+
case_sensitive: bool = False,
|
|
126
|
+
ignore_bots: bool = True,
|
|
127
|
+
rest: NestraREST | None = None,
|
|
128
|
+
on_error: ErrorFunc | None = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
if not prefix:
|
|
131
|
+
raise ValueError("Prefix nie moze byc pusty")
|
|
132
|
+
self.prefix = prefix
|
|
133
|
+
self.case_sensitive = case_sensitive
|
|
134
|
+
self.ignore_bots = ignore_bots
|
|
135
|
+
self.rest = rest
|
|
136
|
+
self.on_error = on_error
|
|
137
|
+
self._commands: dict[str, Command] = {}
|
|
138
|
+
|
|
139
|
+
def __len__(self) -> int:
|
|
140
|
+
return len(self._commands)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def commands(self) -> list[Command]:
|
|
144
|
+
return list(self._commands.values())
|
|
145
|
+
|
|
146
|
+
def _key(self, name: str) -> str:
|
|
147
|
+
return name if self.case_sensitive else name.lower()
|
|
148
|
+
|
|
149
|
+
def command(
|
|
150
|
+
self,
|
|
151
|
+
name: str,
|
|
152
|
+
*,
|
|
153
|
+
aliases: Sequence[str] = (),
|
|
154
|
+
description: str = "",
|
|
155
|
+
usage: str | None = None,
|
|
156
|
+
) -> Callable[[CommandFunc], CommandFunc]:
|
|
157
|
+
"""Dekorator rejestrujacy komende."""
|
|
158
|
+
if not name or any(character.isspace() for character in name):
|
|
159
|
+
raise ValueError("Nazwa komendy nie moze byc pusta ani zawierac spacji")
|
|
160
|
+
alias_tuple = tuple(aliases)
|
|
161
|
+
for alias in alias_tuple:
|
|
162
|
+
if not alias or any(character.isspace() for character in alias):
|
|
163
|
+
raise ValueError("Alias komendy nie moze byc pusty ani zawierac spacji")
|
|
164
|
+
candidates = (name, *alias_tuple)
|
|
165
|
+
keys = [self._key(candidate) for candidate in candidates]
|
|
166
|
+
if len(set(keys)) != len(keys):
|
|
167
|
+
raise ValueError("Nazwa komendy i aliasy musza byc unikalne")
|
|
168
|
+
|
|
169
|
+
def decorator(func: CommandFunc) -> CommandFunc:
|
|
170
|
+
command = Command(
|
|
171
|
+
name=name,
|
|
172
|
+
func=func,
|
|
173
|
+
aliases=alias_tuple,
|
|
174
|
+
description=description,
|
|
175
|
+
usage=usage,
|
|
176
|
+
)
|
|
177
|
+
for candidate in (name, *alias_tuple):
|
|
178
|
+
key = self._key(candidate)
|
|
179
|
+
if key in self._commands:
|
|
180
|
+
raise ValueError(f"Komenda lub alias juz istnieje: {candidate}")
|
|
181
|
+
for candidate in (name, *alias_tuple):
|
|
182
|
+
self._commands[self._key(candidate)] = command
|
|
183
|
+
return func
|
|
184
|
+
|
|
185
|
+
return decorator
|
|
186
|
+
|
|
187
|
+
def get(self, name: str) -> Command | None:
|
|
188
|
+
return self._commands.get(self._key(name))
|
|
189
|
+
|
|
190
|
+
def parse(self, content: str) -> ParsedCommand | None:
|
|
191
|
+
return parse_command(content, self.prefix)
|
|
192
|
+
|
|
193
|
+
async def handle_message(self, message: Message) -> bool:
|
|
194
|
+
"""Wywoluje komende, jesli wiadomosc ja zawiera. Zwraca True gdy dopasowano."""
|
|
195
|
+
author = message.author
|
|
196
|
+
if author is None:
|
|
197
|
+
return False
|
|
198
|
+
if self.ignore_bots and author.is_bot:
|
|
199
|
+
return False
|
|
200
|
+
parsed = self.parse(message.content)
|
|
201
|
+
if parsed is None:
|
|
202
|
+
return False
|
|
203
|
+
command = self.get(parsed.name)
|
|
204
|
+
if command is None:
|
|
205
|
+
return False
|
|
206
|
+
context = CommandContext(
|
|
207
|
+
message=message,
|
|
208
|
+
command=command,
|
|
209
|
+
args=parsed.args,
|
|
210
|
+
argv=parsed.argv or [],
|
|
211
|
+
rest=self.rest,
|
|
212
|
+
)
|
|
213
|
+
try:
|
|
214
|
+
result = command.func(context)
|
|
215
|
+
if inspect.isawaitable(result):
|
|
216
|
+
await result
|
|
217
|
+
except Exception as error: # noqa: BLE001 - blad komendy nie moze ubic bota
|
|
218
|
+
await self._handle_error(context, error)
|
|
219
|
+
return True
|
|
220
|
+
|
|
221
|
+
async def _handle_error(self, context: CommandContext, error: Exception) -> None:
|
|
222
|
+
if self.on_error is None:
|
|
223
|
+
logger.exception("Blad komendy %s: %s", context.command.name, error)
|
|
224
|
+
return
|
|
225
|
+
try:
|
|
226
|
+
result = self.on_error(context, error)
|
|
227
|
+
if inspect.isawaitable(result):
|
|
228
|
+
await result
|
|
229
|
+
except Exception: # noqa: BLE001
|
|
230
|
+
logger.exception("Blad w handlerze bledow komend")
|
|
231
|
+
|
|
232
|
+
def help_text(self) -> str:
|
|
233
|
+
"""Tekst pomocy generowany z opisow komend (bez duplikatow aliasow)."""
|
|
234
|
+
lines = []
|
|
235
|
+
seen: set[int] = set()
|
|
236
|
+
for command in self._commands.values():
|
|
237
|
+
if id(command) in seen:
|
|
238
|
+
continue
|
|
239
|
+
seen.add(id(command))
|
|
240
|
+
usage = command.usage or command.name
|
|
241
|
+
line = f"{self.prefix}{usage}"
|
|
242
|
+
if command.description:
|
|
243
|
+
line += f" - {command.description}"
|
|
244
|
+
if command.aliases:
|
|
245
|
+
line += f" (aliasy: {', '.join(command.aliases)})"
|
|
246
|
+
lines.append(line)
|
|
247
|
+
return "\n".join(lines)
|