Skip to content
This repository was archived by the owner on Feb 11, 2026. It is now read-only.

Commit db9a4d7

Browse files
committed
Добавлено управление обработкой ошибок, улучшена обработка SSL-соединений и добавлена поддержка регистрации аккаунтов. Обновлены зависимости и улучшена структура кода.
1 parent 2f55acb commit db9a4d7

9 files changed

Lines changed: 190 additions & 45 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ dev = [
6969
"pytest-asyncio>=0.24.0",
7070
"pytest-cov>=5.0.0",
7171
"pytest-timeout>=2.1.0",
72+
"python-socks[asyncio]>=2.8.0",
7273
]
7374

7475
[tool.hatch.build.targets.wheel]

src/pymax/core.py

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import socket
77
import ssl
88
import time
9+
import traceback
910
from collections.abc import Awaitable
1011
from pathlib import Path
1112
from typing import TYPE_CHECKING, Any, ClassVar, Literal
@@ -15,6 +16,7 @@
1516

1617
from .crud import Database
1718
from .exceptions import (
19+
Error,
1820
InvalidPhoneError,
1921
SocketNotConnectedError,
2022
WebSocketNotConnectedError,
@@ -171,14 +173,20 @@ def __init__(
171173
self._on_reaction_change_handlers: list[Callable[[str, int, ReactionInfo], Any]] = []
172174
self._on_chat_update_handlers: list[Callable[[Chat], Any | Awaitable[Any]]] = []
173175
self._on_raw_receive_handlers: list[Callable[[dict[str, Any]], Any | Awaitable[Any]]] = []
176+
self._on_error_handler: Callable[[Exception], Any | Awaitable[Any]] | None = None
174177
self._scheduled_tasks: list[tuple[Callable[[], Any | Awaitable[Any]], float]] = []
175178

176179
self._ssl_context = ssl.create_default_context()
177-
self._ssl_context.set_ciphers("DEFAULT")
180+
self._ssl_context.set_ciphers(
181+
"DEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:!aDSS:!SRP:!PSK"
182+
)
178183
self._ssl_context.check_hostname = True
179184
self._ssl_context.verify_mode = ssl.CERT_REQUIRED
180185
self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
186+
187+
self._ssl_context.session_stats()
181188
self._ssl_context.load_default_certs()
189+
self._ssl_context.set_ciphers("DEFAULT")
182190
self._socket: socket.socket | None = None
183191
self._ws: websockets.ClientConnection | None = None
184192

@@ -225,17 +233,20 @@ async def _post_login_tasks(self, sync: bool = True) -> None:
225233
await self._sync()
226234

227235
self.logger.debug("is_connected=%s before starting ping", self.is_connected)
228-
ping_task = asyncio.create_task(self._send_interactive_ping())
229-
ping_task.add_done_callback(self._log_task_exception)
230-
self._background_tasks.add(ping_task)
231-
232-
start_scheduled_task = asyncio.create_task(self._start_scheduled_tasks())
233-
start_scheduled_task.add_done_callback(self._log_task_exception)
236+
ping_task = self._create_safe_task(
237+
self._send_interactive_ping(),
238+
name="interactive_ping",
239+
)
234240

241+
start_scheduled_task = self._create_safe_task(
242+
self._start_scheduled_tasks(),
243+
name="start_scheduled_tasks",
244+
)
235245
if self._send_fake_telemetry:
236-
telemetry_task = asyncio.create_task(self._start())
237-
telemetry_task.add_done_callback(self._log_task_exception)
238-
self._background_tasks.add(telemetry_task)
246+
telemetry_task = self._create_safe_task(
247+
self._start(),
248+
name="fake_telemetry",
249+
)
239250

240251
if self._on_start_handler:
241252
self.logger.debug("Calling on_start handler")
@@ -324,13 +335,26 @@ async def start(self) -> None:
324335
task.cancel()
325336
with contextlib.suppress(asyncio.CancelledError):
326337
await task
327-
338+
except Error as e:
339+
self.logger.exception(f"Client stopped with error: {e}")
340+
if self._on_error_handler:
341+
try:
342+
result = self._on_error_handler(e)
343+
if asyncio.iscoroutine(result):
344+
await self._safe_execute(result, context="on_error handler")
345+
except Exception:
346+
self.logger.exception("Unhandled exception in on_error handler")
347+
raise
348+
else:
349+
tb = traceback.format_exc()
350+
self.logger.error(f"Traceback:\n{tb}")
328351
except asyncio.CancelledError:
329352
self.logger.info("Client task cancelled, stopping")
330353
break
331-
except Exception as e:
332-
self.logger.exception("Client start iteration failed")
333-
raise e
354+
except Exception:
355+
if not self.reconnect:
356+
self.logger.exception("Client start iteration failed, no reconnect configured")
357+
raise
334358
finally:
335359
await self._cleanup_client()
336360

src/pymax/formatter.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,4 @@ def format(self, record: logging.LogRecord) -> str:
2828
f"{message_color}{record.getMessage()}{self.RESET}"
2929
)
3030

31-
if record.exc_info and record.exc_info[0] is not None:
32-
log += "\n" + self.formatException(record.exc_info)
33-
3431
return log

src/pymax/mixins/auth.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,13 @@ async def _login(self) -> None:
208208

209209
password_challenge = login_resp.get("passwordChallenge")
210210
login_attrs = login_resp.get("tokenAttrs", {}).get("LOGIN", {})
211+
reg_attrs = login_resp.get("tokenAttrs", {}).get("REGISTER", {})
212+
213+
if reg_attrs:
214+
self.logger.info(
215+
"Account requires registration. Setup registration info in client parameters."
216+
)
217+
raise RuntimeError("Account requires registration")
211218

212219
if password_challenge and not login_attrs:
213220
token = await self._two_factor_auth(password_challenge)

src/pymax/mixins/handler.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from collections.abc import Awaitable, Callable
22
from typing import Any
33

4+
from pymax.exceptions import Error
45
from pymax.filters import BaseFilter
56
from pymax.protocols import ClientProtocol
67
from pymax.types import Chat, Message, ReactionInfo
@@ -183,6 +184,21 @@ def decorator(
183184

184185
return decorator
185186

187+
def on_error(
188+
self, handler: Callable[[Exception], Any | Awaitable[Any]]
189+
) -> Callable[[Exception], Any | Awaitable[Any]]:
190+
"""
191+
Устанавливает обработчик ошибок.
192+
193+
:param handler: Функция или coroutine с аргументом (exception: Exception).
194+
:type handler: Callable[[Exception], Any | Awaitable[Any]]
195+
:return: Установленный обработчик.
196+
:rtype: Callable[[Exception], Any | Awaitable[Any]]
197+
"""
198+
self._on_error_handler = handler
199+
self.logger.debug("on_error handler set: %r", handler)
200+
return handler
201+
186202
def add_message_handler(
187203
self,
188204
handler: Callable[[Message], Any | Awaitable[Any]],

src/pymax/mixins/scheduler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,6 @@ async def _run_periodic(
2323

2424
async def _start_scheduled_tasks(self) -> None:
2525
for func, interval in self._scheduled_tasks:
26-
task = asyncio.create_task(self._run_periodic(func, interval))
26+
task = self._create_safe_task(self._run_periodic(func, interval))
2727
self._background_tasks.add(task)
2828
task.add_done_callback(self._background_tasks.discard)

src/pymax/mixins/socket.py

Lines changed: 119 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import asyncio
2+
import contextlib
23
import socket
34
import ssl
45
import sys
56
import time
7+
import traceback
68
from collections.abc import Callable
79
from typing import Any, Literal
810
from urllib.parse import urlparse
@@ -35,6 +37,11 @@
3537

3638

3739
class SocketMixin(BaseTransport):
40+
def _close_socket_safely(self) -> None:
41+
if self._socket is not None:
42+
with contextlib.suppress(ssl.SSLError, Exception):
43+
self._socket.close()
44+
3845
@property
3946
def sock(self) -> socket.socket:
4047
if self._socket is None or not self.is_connected:
@@ -184,6 +191,31 @@ def _create_socket_with_proxy(self, proxy: str) -> socket.socket:
184191

185192
return sock
186193

194+
def _perform_ssl_handshake(self, raw_sock: socket.socket) -> socket.socket:
195+
"""
196+
Выполняет SSL handshake с сервером.
197+
198+
:param raw_sock: Обычный сокет
199+
:return: SSL сокет
200+
"""
201+
raw_sock.setblocking(True)
202+
203+
try:
204+
raw_sock.settimeout(10.0)
205+
wrapped = self._ssl_context.wrap_socket(
206+
raw_sock,
207+
server_hostname=self.host,
208+
do_handshake_on_connect=True,
209+
suppress_ragged_eofs=True,
210+
)
211+
wrapped.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
212+
wrapped.setblocking(False)
213+
return wrapped
214+
except ssl.SSLError as e:
215+
self.logger.error("SSL handshake failed: %s", e)
216+
raw_sock.close()
217+
raise
218+
187219
async def connect(self, user_agent: UserAgentPayload | None = None) -> dict[str, Any]:
188220
"""
189221
Устанавливает соединение с сервером и выполняет handshake.
@@ -213,27 +245,51 @@ async def connect(self, user_agent: UserAgentPayload | None = None) -> dict[str,
213245
)
214246
else:
215247
raw_sock = await loop.run_in_executor(
216-
None, lambda: socket.create_connection((self.host, self.port))
248+
None, lambda: socket.create_connection((self.host, self.port), timeout=10.0)
249+
)
250+
251+
try:
252+
self._socket = await asyncio.wait_for(
253+
loop.run_in_executor(None, lambda: self._perform_ssl_handshake(raw_sock)),
254+
timeout=15.0,
217255
)
218-
self._socket = self._ssl_context.wrap_socket(raw_sock, server_hostname=self.host)
219-
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
256+
except asyncio.TimeoutError:
257+
raw_sock.close()
258+
self.logger.error("SSL handshake timeout")
259+
raise
260+
220261
self.is_connected = True
221262
self._incoming = asyncio.Queue()
222263
self._outgoing = asyncio.Queue()
223264
self._pending = {}
224-
self._recv_task = asyncio.create_task(self._recv_loop())
225-
self._outgoing_task = asyncio.create_task(self._outgoing_loop())
265+
self._recv_task = self._create_safe_task(self._recv_loop(), name="recv_loop socket task")
266+
self._outgoing_task = self._create_safe_task(
267+
self._outgoing_loop(), name="outgoing_loop socket task"
268+
)
226269
self.logger.info("Socket connected, starting handshake")
227270
return await self._handshake(user_agent)
228271

229272
def _recv_exactly(self, sock: socket.socket, n: int) -> bytes:
273+
"""
274+
Получает ровно n байт из сокета. Обрабатывает SSL ошибки корректно.
275+
"""
230276
buf = bytearray()
231-
while len(buf) < n:
232-
chunk = sock.recv(n - len(buf))
233-
if not chunk:
234-
return bytes(buf)
235-
buf.extend(chunk)
236-
return bytes(buf)
277+
try:
278+
while len(buf) < n:
279+
try:
280+
chunk = sock.recv(n - len(buf))
281+
except ssl.SSLWantReadError:
282+
continue
283+
except ssl.SSLWantWriteError:
284+
continue
285+
286+
if not chunk:
287+
break
288+
buf.extend(chunk)
289+
return bytes(buf)
290+
except (ssl.SSLError, ConnectionError, BrokenPipeError) as e:
291+
self.logger.debug("SSL/Connection error in _recv_exactly: %s", e)
292+
raise
237293

238294
async def _parse_header(
239295
self, loop: asyncio.AbstractEventLoop, sock: socket.socket
@@ -299,6 +355,8 @@ async def _recv_loop(self) -> None:
299355

300356
sock = self._socket
301357
loop = asyncio.get_running_loop()
358+
consecutive_errors = 0
359+
max_consecutive_errors = 3
302360

303361
while True:
304362
try:
@@ -312,6 +370,8 @@ async def _recv_loop(self) -> None:
312370
if not datas:
313371
continue
314372

373+
consecutive_errors = 0
374+
315375
for data_item in datas:
316376
seq = data_item.get("seq")
317377

@@ -326,20 +386,58 @@ async def _recv_loop(self) -> None:
326386
except asyncio.CancelledError:
327387
self.logger.debug("Recv loop cancelled")
328388
raise
329-
except Exception:
330-
self.logger.exception("Error in recv_loop")
389+
except (
390+
ssl.SSLError,
391+
ssl.SSLEOFError,
392+
ConnectionResetError,
393+
BrokenPipeError,
394+
) as ssl_err:
395+
consecutive_errors += 1
396+
self.logger.error(
397+
"SSL/Connection error in recv_loop (error %d/%d): %s",
398+
consecutive_errors,
399+
max_consecutive_errors,
400+
ssl_err,
401+
)
331402
self.is_connected = False
332403

333-
if self.reconnect:
404+
self._close_socket_safely()
405+
406+
if self.reconnect and consecutive_errors < max_consecutive_errors:
334407
self.logger.info("Reconnect enabled, attempting to restore connection...")
335408
try:
336-
await asyncio.sleep(self.reconnect_delay)
409+
await asyncio.sleep(min(2**consecutive_errors, 10))
337410
await self.connect(self.user_agent)
338411
sock = self._socket
339412
self.logger.info("Connection restored successfully")
340413
except Exception:
341-
self.logger.exception("Failed to restore connection, exiting recv_loop")
342-
break
414+
self.logger.exception("Failed to restore connection")
415+
if consecutive_errors >= max_consecutive_errors:
416+
self.logger.error(
417+
"Max reconnection attempts reached, exiting recv_loop"
418+
)
419+
break
420+
else:
421+
self.logger.warning(
422+
"Reconnect disabled or max errors reached, exiting recv_loop"
423+
)
424+
break
425+
except Exception as e:
426+
consecutive_errors += 1
427+
self.logger.exception("Error in recv_loop: %s", e)
428+
self.is_connected = False
429+
430+
if self.reconnect and consecutive_errors < max_consecutive_errors:
431+
self.logger.info("Reconnect enabled, attempting to restore connection...")
432+
try:
433+
await asyncio.sleep(min(2**consecutive_errors, 10))
434+
await self.connect(self.user_agent)
435+
sock = self._socket
436+
self.logger.info("Connection restored successfully")
437+
except Exception:
438+
self.logger.exception("Failed to restore connection")
439+
if consecutive_errors >= max_consecutive_errors:
440+
break
343441
else:
344442
self.logger.warning("Reconnect disabled, exiting recv_loop")
345443
break
@@ -389,15 +487,11 @@ async def _send_and_wait(
389487
)
390488
return data
391489

392-
except (ssl.SSLEOFError, ssl.SSLError, ConnectionError) as conn_err:
393-
self.logger.warning("Connection lost, reconnecting...")
490+
except (ssl.SSLEOFError, ssl.SSLError, ConnectionError, BrokenPipeError) as conn_err:
491+
self.logger.warning("Connection lost: %s, attempting reconnect...", conn_err)
394492
self.is_connected = False
395-
try:
396-
await self.connect(self.user_agent)
397-
except Exception as exc:
398-
self.logger.exception("Reconnect failed")
399-
raise exc from conn_err
400-
raise SocketNotConnectedError from conn_err
493+
494+
self._close_socket_safely()
401495
except asyncio.TimeoutError:
402496
self.logger.exception("Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"])
403497
raise SocketSendError from None

0 commit comments

Comments
 (0)