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 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)