Skip to content

Commit c03a7db

Browse files
committed
Рефакторинг обработки ошибок и структуры кода в миксинах: добавлен MixinsUtils, обновлена обработка ошибок, упрощено логирование, переименован JoinGroupPayload в JoinChatPayload, Enum для типобезопасности, удалён utils.py, улучшено логирование.
1 parent 20081ce commit c03a7db

22 files changed

Lines changed: 857 additions & 906 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ Thumbs.db
103103
session.db
104104
*.bak
105105
*.swp
106+
*.bin
106107

107108
# Environment / secrets
108109
.env
@@ -114,6 +115,7 @@ cache/
114115

115116
# Keep lockfiles and important configs tracked? If you want to track specific lockfiles,
116117
# remove them from this .gitignore (for example: remove poetry.lock or uv.lock).
118+
tests2/
117119
tests/
118120

119121

examples/example.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,23 @@ async def handle_deleted_message(message: Message) -> None:
3030
@client.on_start
3131
async def handle_start() -> None:
3232
print(f"Client started successfully at {datetime.datetime.now()}!")
33-
file_path = "ruff.toml"
34-
file = File(path=file_path)
35-
msg = await client.send_message(
36-
text="Here is the file you requested.",
37-
chat_id=0,
38-
attachment=file,
39-
notify=True,
40-
)
41-
if msg:
42-
print(f"File sent successfully in message ID: {msg.id}")
33+
print(client.me.id)
34+
35+
# await client.send_message(
36+
# "Hello, this is a test message sent upon client start!",
37+
# chat_id=23424,
38+
# notify=True,
39+
# )
40+
# file_path = "ruff.toml"
41+
# file = File(path=file_path)
42+
# msg = await client.send_message(
43+
# text="Here is the file you requested.",
44+
# chat_id=0,
45+
# attachment=file,
46+
# notify=True,
47+
# )
48+
# if msg:
49+
# print(f"File sent successfully in message ID: {msg.id}")
4350
# history = await client.fetch_history(chat_id=0)
4451
# if history:
4552
# for message in history:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "maxapi-python"
3-
version = "1.1.15"
3+
version = "1.1.16"
44
description = "Python wrapper для API мессенджера Max"
55
readme = "README.md"
66
requires-python = ">=3.10"

src/pymax/core.py

Lines changed: 87 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
import socket
44
import ssl
55
import time
6+
import traceback
7+
from collections.abc import Awaitable
68
from pathlib import Path
7-
from typing import Any, Literal
9+
from typing import TYPE_CHECKING, Any, Literal
810

911
from typing_extensions import override
1012

1113
from .crud import Database
1214
from .exceptions import InvalidPhoneError
15+
from .formatter import ColoredFormatter
1316
from .mixins import ApiMixin, SocketMixin, WebSocketMixin
1417
from .payloads import UserAgentPayload
1518
from .static.constant import (
@@ -18,6 +21,16 @@
1821
WEBSOCKET_URI,
1922
)
2023

24+
if TYPE_CHECKING:
25+
from collections.abc import Awaitable, Callable
26+
from typing import Any
27+
28+
import websockets
29+
30+
from .filters import Filter
31+
from .types import Channel, Chat, Dialog, Me, Message, User
32+
33+
2134
logger = logging.getLogger(__name__)
2235

2336

@@ -64,9 +77,7 @@ def __init__(
6477
last_name: str | None = None,
6578
logger: logging.Logger | None = None,
6679
) -> None:
67-
logger = logger or logging.getLogger(f"{__name__}.MaxClient")
68-
ApiMixin.__init__(self, token=token, logger=logger)
69-
WebSocketMixin.__init__(self, token=token, logger=logger)
80+
self.logger = logger or logging.getLogger(f"{__name__}")
7081
self.uri: str = uri
7182
self.phone: str = phone
7283
if not self._check_phone():
@@ -77,32 +88,64 @@ def __init__(
7788
self.first_name: str = first_name
7889
self.last_name: str | None = last_name
7990
self.proxy: str | Literal[True] | None = proxy
91+
92+
self.is_connected: bool = False
93+
94+
self.chats: list[Chat] = []
95+
self.dialogs: list[Dialog] = []
96+
self.channels: list[Channel] = []
97+
self.me: Me | None = None
98+
self._users: dict[int, User] = {}
99+
80100
self._work_dir: str = work_dir
81101
self._database_path: Path = Path(work_dir) / "session.db"
82102
self._database_path.parent.mkdir(parents=True, exist_ok=True)
83103
self._database_path.touch(exist_ok=True)
84104
self._database = Database(self._work_dir)
105+
106+
self._incoming: asyncio.Queue[dict[str, Any]] | None = None
85107
self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
108+
self._recv_task: asyncio.Task[Any] | None = None
86109
self._outgoing_task: asyncio.Task[Any] | None = None
110+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
111+
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
112+
self._background_tasks: set[asyncio.Task[Any]] = set()
113+
114+
self._seq: int = 0
87115
self._error_count: int = 0
88116
self._circuit_breaker: bool = False
89117
self._last_error_time: float = 0.0
118+
90119
self._device_id = self._database.get_device_id()
91-
self._file_upload_waiters: dict[
92-
int, asyncio.Future[dict[str, Any]]
93-
] = {}
120+
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
121+
94122
self._token = self._database.get_auth_token() or token
95123
self.user_agent = headers
96124
self._send_fake_telemetry: bool = send_fake_telemetry
97125
self._session_id: int = int(time.time() * 1000)
98126
self._action_id: int = 1
127+
self._current_screen: str = "chats_list_tab"
128+
129+
self._on_message_handlers: list[
130+
tuple[Callable[[Message], Any], Filter | None]
131+
] = []
132+
self._on_message_edit_handlers: list[
133+
tuple[Callable[[Message], Any], Filter | None]
134+
] = []
135+
self._on_message_delete_handlers: list[
136+
tuple[Callable[[Message], Any], Filter | None]
137+
] = []
138+
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
139+
99140
self._ssl_context = ssl.create_default_context()
100141
self._ssl_context.set_ciphers("DEFAULT")
101142
self._ssl_context.check_hostname = True
102143
self._ssl_context.verify_mode = ssl.CERT_REQUIRED
103144
self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
104145
self._ssl_context.load_default_certs()
105146
self._socket: socket.socket | None = None
147+
self._ws: websockets.ClientConnection | None = None
148+
106149
self._setup_logger()
107150
self.logger.debug(
108151
"Initialized MaxClient uri=%s work_dir=%s",
@@ -111,20 +154,35 @@ def __init__(
111154
)
112155

113156
def _setup_logger(self) -> None:
114-
if not logger.handlers:
157+
if not self.logger.handlers:
158+
if not self.logger.level:
159+
self.logger.setLevel(logging.INFO)
115160
handler = logging.StreamHandler()
116-
formatter = logging.Formatter(
117-
"%(asctime)s [%(levelname)s] %(name)s: %(message)s"
161+
formatter = ColoredFormatter(
162+
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
163+
datefmt="%Y-%m-%d %H:%M:%S",
118164
)
119165
handler.setFormatter(formatter)
120-
logger.addHandler(handler)
166+
self.logger.addHandler(handler)
121167

122168
async def _wait_forever(self):
123169
try:
124170
await self.ws.wait_closed()
125171
except asyncio.CancelledError:
126172
self.logger.debug("wait_closed cancelled")
127173

174+
async def _safe_execute(self, coro, *, context: str = "unknown"):
175+
"""
176+
Безопасно выполняет пользовательскую корутину.
177+
Логирует traceback, но не роняет event loop.
178+
"""
179+
try:
180+
return await coro
181+
except Exception as e:
182+
self.logger.error(
183+
f"Unhandled exception in {context}: {e}\n{traceback.format_exc()}"
184+
)
185+
128186
async def close(self) -> None:
129187
try:
130188
self.logger.info("Closing client")
@@ -147,6 +205,23 @@ async def close(self) -> None:
147205
except Exception:
148206
self.logger.exception("Error closing client")
149207

208+
def _create_safe_task(self, coro: Awaitable[Any], *, name: str | None = None):
209+
async def runner():
210+
try:
211+
return await coro
212+
except asyncio.CancelledError:
213+
raise
214+
except Exception as e:
215+
self.logger.error(
216+
f"Unhandled exception in task {name or coro}: {e}",
217+
exc_info=e,
218+
)
219+
return None
220+
221+
task = asyncio.create_task(runner(), name=name)
222+
self._background_tasks.add(task)
223+
return task
224+
150225
async def start(self) -> None:
151226
"""
152227
Запускает клиент, подключается к WebSocket, авторизует
@@ -173,7 +248,7 @@ async def start(self) -> None:
173248
self.logger.debug("Calling on_start handler")
174249
result = self._on_start_handler()
175250
if asyncio.iscoroutine(result):
176-
await result
251+
await self._safe_execute(result, context="on_start handler")
177252

178253
ping_task = asyncio.create_task(self._send_interactive_ping())
179254
ping_task.add_done_callback(self._log_task_exception)

src/pymax/exceptions.py

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,6 @@ def __init__(self) -> None:
3939
super().__init__("Send and wait failed (socket)")
4040

4141

42-
class LoginError(Exception):
43-
"""
44-
Исключение, вызываемое при ошибке авторизации.
45-
"""
46-
47-
def __init__(self, message: str) -> None:
48-
super().__init__(f"Login error: {message}")
49-
50-
5142
class ResponseError(Exception):
5243
"""
5344
Исключение, вызываемое при ошибке в ответе от сервера.
@@ -64,3 +55,54 @@ class ResponseStructureError(Exception):
6455

6556
def __init__(self, message: str) -> None:
6657
super().__init__(f"Response structure error: {message}")
58+
59+
60+
class Error(Exception):
61+
"""
62+
Базовое исключение для ошибок PyMax.
63+
"""
64+
65+
def __init__(
66+
self,
67+
error: str,
68+
message: str,
69+
title: str,
70+
localized_message: str | None = None,
71+
) -> None:
72+
self.error = error
73+
self.message = message
74+
self.title = title
75+
self.localized_message = localized_message
76+
77+
parts = []
78+
if localized_message:
79+
parts.append(localized_message)
80+
if message:
81+
parts.append(message)
82+
if title:
83+
parts.append(f"({title})")
84+
parts.append(f"[{error}]")
85+
86+
super().__init__("PyMax Error: " + " ".join(parts))
87+
88+
89+
class RateLimitError(Error):
90+
"""
91+
Исключение, вызываемое при превышении лимита запросов.
92+
"""
93+
94+
def __init__(
95+
self, error: str, message: str, title: str, localized_message: str | None = None
96+
) -> None:
97+
super().__init__(error, message, title, localized_message)
98+
99+
100+
class LoginError(Error):
101+
"""
102+
Исключение, вызываемое при ошибке авторизации.
103+
"""
104+
105+
def __init__(
106+
self, error: str, message: str, title: str, localized_message: str | None = None
107+
) -> None:
108+
super().__init__(error, message, title, localized_message)

src/pymax/formatter.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import logging
2+
from typing import ClassVar
3+
4+
5+
class ColoredFormatter(logging.Formatter):
6+
COLORS: ClassVar = {
7+
"DEBUG": "\033[37m",
8+
"INFO": "\033[36m",
9+
"WARNING": "\033[33m",
10+
"ERROR": "\033[31m",
11+
"CRITICAL": "\033[41m",
12+
}
13+
14+
RESET = "\033[0m"
15+
DIM = "\033[2m"
16+
BOLD = "\033[1m"
17+
18+
def format(self, record: logging.LogRecord) -> str:
19+
level_color = self.COLORS.get(record.levelname, self.RESET)
20+
time_color = self.DIM
21+
name_color = "\033[35m"
22+
message_color = self.RESET
23+
24+
log = (
25+
f"{time_color}{self.formatTime(record, '%H:%M:%S')}{self.RESET} "
26+
f"[{level_color}{record.levelname}{self.RESET}] "
27+
f"{name_color}{record.name}{self.RESET}: "
28+
f"{message_color}{record.getMessage()}{self.RESET}"
29+
)
30+
return log

src/pymax/interfaces.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def __init__(self, logger: Logger) -> None:
4141
self.registration: bool
4242
self.first_name: str
4343
self.last_name: str | None
44+
self._token: str | None
4445
self._work_dir: str
4546
self._database_path: Path
4647
self._ws: websockets.ClientConnection | None = None
@@ -100,3 +101,9 @@ async def _queue_message(
100101
max_retries: int = 3,
101102
) -> Message | None:
102103
pass
104+
105+
@abstractmethod
106+
def _create_safe_task(
107+
self, coro: Awaitable[Any], name: str | None = None
108+
) -> asyncio.Task[Any]:
109+
pass

0 commit comments

Comments
 (0)