maxapi-python 1.1.15__py3-none-any.whl → 1.1.17__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxapi-python
3
- Version: 1.1.15
3
+ Version: 1.1.17
4
4
  Summary: Python wrapper для API мессенджера Max
5
5
  Project-URL: Homepage, https://github.com/ink-developer/PyMax
6
6
  Project-URL: Repository, https://github.com/ink-developer/PyMax
@@ -155,10 +155,14 @@ if __name__ == "__main__":
155
155
 
156
156
  [Telegram](https://t.me/pymax_news)
157
157
 
158
- ## Авторы
158
+ ## Star History
159
+
160
+ [![Star History Chart](https://api.star-history.com/svg?repos=ink-developer/PyMax&type=date&legend=top-left)](https://www.star-history.com/#ink-developer/PyMax&type=date&legend=top-left)
159
161
 
160
- - **[ink-developer](https://github.com/ink-developer)** — Оригинальный автор проекта
162
+ ## Авторы
161
163
  - **[ink](https://github.com/ink-developer)** — Главный разработчик, исследование API и его документация
164
+ - **[noxzion](https://github.com/noxzion)** — Оригинальный автор проекта
165
+
162
166
 
163
167
  ## Контрибьюторы
164
168
 
@@ -0,0 +1,31 @@
1
+ pymax/__init__.py,sha256=6wUKKwsyxFpWG3b7kwptOvHd-w78C-ygw42iCDBYQvc,1915
2
+ pymax/core.py,sha256=uSmIYVBqiO-3v0wtehObnvOEJ3n_dpG62TXTKwmdFf4,13838
3
+ pymax/crud.py,sha256=wmJh8MPi3L_HbYp7MJP0eXfDcnjgfkLDa9rHAmXtkow,3219
4
+ pymax/exceptions.py,sha256=nDUNx7bM-Yjugj-qfIllcrnwLg9JpZroYqfXapjYbMQ,3178
5
+ pymax/files.py,sha256=dRuOpvoJZWiH4xa_HVGyqQ-_Zzj-sVikElHmrPjwgs0,3166
6
+ pymax/filters.py,sha256=4hehzyQlyxBxLiEhtSH_KQdFHx8gYaqyIOOD72EsSS0,1536
7
+ pymax/formatter.py,sha256=OVsTwambHhXlOMd0wVECJWuB_S2wSEVKdMLCzgPcvYQ,859
8
+ pymax/formatting.py,sha256=hhtmakfcQDzQRsAckPunnJOCKY2lFdLeIp8Yw53yY8s,2522
9
+ pymax/interfaces.py,sha256=WWKNGT725GXuYneS9gCOAC6RNtySRs-BTU0fQLyh2OQ,3399
10
+ pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
11
+ pymax/navigation.py,sha256=4ia6RGY2pXMArboNhHkbWlWX7LtcYK1VGVXorPX0Pb4,5747
12
+ pymax/payloads.py,sha256=qaafULDGBXsQ7gNFC374wZVUwN5tzJLHwkxtAmglOzU,6292
13
+ pymax/types.py,sha256=RaLn9bUpkxO0SKbDMIHnoFeqV6gqOl2pKDNCa2LxTRI,32102
14
+ pymax/mixins/__init__.py,sha256=xvjcq-lFVHCPss_t8xxXya0OJnsh-owlBqtUlrXSCcw,695
15
+ pymax/mixins/auth.py,sha256=H4Zp3n8cwpv4Q3Mn1_Kb7Oh9DbTL7T9GcWJ6R1JN7ls,6672
16
+ pymax/mixins/channel.py,sha256=dMuJRnbqZisN8kcPFCCe1sIOOBQl2uT4P49PpZXcoKE,5206
17
+ pymax/mixins/group.py,sha256=7oa7RpiqnlcnAsnIHOfSiujNYAzUZ9lkTy9NGW5KVUE,8654
18
+ pymax/mixins/handler.py,sha256=AhxIRvwftkuWN435_CXede2ZVWrDde4zkMPZtwIm5IU,3892
19
+ pymax/mixins/message.py,sha256=ezU9d6r4MkYjH67gZ9SFLYPKqo4Nb6lswqDsEW5p-Bg,22329
20
+ pymax/mixins/self.py,sha256=tDQrUdUpsCu7qGkWLtKxTfTHPHU5_r3qsn-eptHG2KY,1198
21
+ pymax/mixins/socket.py,sha256=j6XTo_M3rNw-az2PfSW6oJ_YHg9M7cWARY4cXpMllDY,22256
22
+ pymax/mixins/telemetry.py,sha256=LWr68DNQkPhAjGRDYQ5lORXxC3Yw6M9E8sF0TCNISTE,3609
23
+ pymax/mixins/user.py,sha256=5utoK7Z-7lySOg0PEO69b6h_3kjfcnV-YydTZIdNj8g,7120
24
+ pymax/mixins/utils.py,sha256=s3FUf3i_wjn2Gbg5YY1rWZB-90ZEGrrcUuND_MqqSTE,853
25
+ pymax/mixins/websocket.py,sha256=LaL-okzhJCyS3uWV7xsCCKnuff_rooKjAoZ8vkaintY,16817
26
+ pymax/static/constant.py,sha256=Q1NrmaRj17Gdhk3FmUp3HIwrad1TDorq3wFdQlOCzN8,1027
27
+ pymax/static/enum.py,sha256=c_QaLU0Ephe4SuKFIpwpmrf_HCutc34JJ6o4Ik1E6_g,4582
28
+ maxapi_python-1.1.17.dist-info/METADATA,sha256=0gplAk5x9ujWD7fkCZu_yvwataKPPq3fMETJbwHb3HQ,6245
29
+ maxapi_python-1.1.17.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
30
+ maxapi_python-1.1.17.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
31
+ maxapi_python-1.1.17.dist-info/RECORD,,
pymax/__init__.py CHANGED
@@ -9,14 +9,26 @@ from .core import (
9
9
  from .exceptions import (
10
10
  InvalidPhoneError,
11
11
  LoginError,
12
+ ResponseError,
13
+ ResponseStructureError,
14
+ SocketNotConnectedError,
15
+ SocketSendError,
12
16
  WebSocketNotConnectedError,
13
17
  )
18
+ from .files import (
19
+ File,
20
+ Photo,
21
+ )
14
22
  from .static.enum import (
15
23
  AccessType,
24
+ AttachType,
16
25
  AuthType,
17
26
  ChatType,
27
+ ContactAction,
18
28
  DeviceType,
19
29
  ElementType,
30
+ FormattingType,
31
+ MarkupType,
20
32
  MessageStatus,
21
33
  MessageType,
22
34
  Opcode,
@@ -24,10 +36,26 @@ from .static.enum import (
24
36
  from .types import (
25
37
  Channel,
26
38
  Chat,
39
+ Contact,
40
+ ControlAttach,
27
41
  Dialog,
28
42
  Element,
43
+ FileAttach,
44
+ FileRequest,
45
+ Me,
46
+ Member,
29
47
  Message,
48
+ MessageLink,
49
+ Name,
50
+ Names,
51
+ PhotoAttach,
52
+ Presence,
53
+ ReactionCounter,
54
+ ReactionInfo,
55
+ Session,
30
56
  User,
57
+ VideoAttach,
58
+ VideoRequest,
31
59
  )
32
60
 
33
61
  __author__ = "ink-developer"
@@ -35,25 +63,51 @@ __author__ = "ink-developer"
35
63
  __all__ = [
36
64
  # Перечисления и константы
37
65
  "AccessType",
66
+ "AttachType",
38
67
  "AuthType",
39
68
  # Типы данных
40
69
  "Channel",
41
70
  "Chat",
42
71
  "ChatType",
72
+ "Contact",
73
+ "ContactAction",
74
+ "ControlAttach",
43
75
  "DeviceType",
44
76
  "Dialog",
45
77
  "Element",
46
78
  "ElementType",
79
+ "File",
80
+ "FileAttach",
81
+ "FileRequest",
82
+ "FormattingType",
47
83
  # Исключения
48
84
  "InvalidPhoneError",
49
85
  "LoginError",
50
- "WebSocketNotConnectedError",
86
+ "MarkupType",
51
87
  # Клиент
52
88
  "MaxClient",
89
+ "Me",
90
+ "Member",
53
91
  "Message",
92
+ "MessageLink",
54
93
  "MessageStatus",
55
94
  "MessageType",
95
+ "Name",
96
+ "Names",
56
97
  "Opcode",
98
+ "Photo",
99
+ "PhotoAttach",
100
+ "Presence",
101
+ "ReactionCounter",
102
+ "ReactionInfo",
103
+ "ResponseError",
104
+ "ResponseStructureError",
105
+ "Session",
57
106
  "SocketMaxClient",
107
+ "SocketNotConnectedError",
108
+ "SocketSendError",
58
109
  "User",
110
+ "VideoAttach",
111
+ "VideoRequest",
112
+ "WebSocketNotConnectedError",
59
113
  ]
pymax/core.py CHANGED
@@ -1,15 +1,19 @@
1
1
  import asyncio
2
+ import contextlib
2
3
  import logging
3
4
  import socket
4
5
  import ssl
5
6
  import time
7
+ import traceback
8
+ from collections.abc import Awaitable
6
9
  from pathlib import Path
7
- from typing import Literal
10
+ from typing import TYPE_CHECKING, Any, Literal
8
11
 
9
12
  from typing_extensions import override
10
13
 
11
14
  from .crud import Database
12
- from .exceptions import InvalidPhoneError
15
+ from .exceptions import InvalidPhoneError, WebSocketNotConnectedError
16
+ from .formatter import ColoredFormatter
13
17
  from .mixins import ApiMixin, SocketMixin, WebSocketMixin
14
18
  from .payloads import UserAgentPayload
15
19
  from .static.constant import (
@@ -18,6 +22,16 @@ from .static.constant import (
18
22
  WEBSOCKET_URI,
19
23
  )
20
24
 
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Awaitable, Callable
27
+ from typing import Any
28
+
29
+ import websockets
30
+
31
+ from .filters import Filter
32
+ from .types import Channel, Chat, Dialog, Me, Message, User
33
+
34
+
21
35
  logger = logging.getLogger(__name__)
22
36
 
23
37
 
@@ -43,6 +57,8 @@ class MaxClient(ApiMixin, WebSocketMixin):
43
57
  send_fake_telemetry (bool, optional): Флаг отправки фейковой телеметрии. По умолчанию True.
44
58
  proxy (str | Literal[True] | None, optional): Прокси для подключения к WebSocket.
45
59
  (См. https://websockets.readthedocs.io/en/stable/topics/proxies.html).
60
+ reconnect (bool, optional): Флаг автоматического переподключения при потере соединения. По умолчанию True.
61
+
46
62
 
47
63
  Raises:
48
64
  InvalidPhoneError: Если формат номера телефона неверный.
@@ -63,10 +79,10 @@ class MaxClient(ApiMixin, WebSocketMixin):
63
79
  first_name: str = "",
64
80
  last_name: str | None = None,
65
81
  logger: logging.Logger | None = None,
82
+ reconnect: bool = True,
83
+ reconnect_delay: float = 1.0,
66
84
  ) -> 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)
85
+ self.logger = logger or logging.getLogger(f"{__name__}")
70
86
  self.uri: str = uri
71
87
  self.phone: str = phone
72
88
  if not self._check_phone():
@@ -77,23 +93,57 @@ class MaxClient(ApiMixin, WebSocketMixin):
77
93
  self.first_name: str = first_name
78
94
  self.last_name: str | None = last_name
79
95
  self.proxy: str | Literal[True] | None = proxy
96
+ self.reconnect: bool = reconnect
97
+ self.reconnect_delay: float = reconnect_delay
98
+
99
+ self.is_connected: bool = False
100
+
101
+ self.chats: list[Chat] = []
102
+ self.dialogs: list[Dialog] = []
103
+ self.channels: list[Channel] = []
104
+ self.me: Me | None = None
105
+ self._users: dict[int, User] = {}
106
+
80
107
  self._work_dir: str = work_dir
81
108
  self._database_path: Path = Path(work_dir) / "session.db"
82
109
  self._database_path.parent.mkdir(parents=True, exist_ok=True)
83
110
  self._database_path.touch(exist_ok=True)
84
111
  self._database = Database(self._work_dir)
112
+
113
+ self._incoming: asyncio.Queue[dict[str, Any]] | None = None
85
114
  self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
115
+ self._recv_task: asyncio.Task[Any] | None = None
86
116
  self._outgoing_task: asyncio.Task[Any] | None = None
117
+ self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
118
+ self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
119
+ self._background_tasks: set[asyncio.Task[Any]] = set()
120
+
121
+ self._seq: int = 0
87
122
  self._error_count: int = 0
88
123
  self._circuit_breaker: bool = False
89
124
  self._last_error_time: float = 0.0
125
+
90
126
  self._device_id = self._database.get_device_id()
91
127
  self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
128
+
92
129
  self._token = self._database.get_auth_token() or token
93
130
  self.user_agent = headers
94
131
  self._send_fake_telemetry: bool = send_fake_telemetry
95
132
  self._session_id: int = int(time.time() * 1000)
96
133
  self._action_id: int = 1
134
+ self._current_screen: str = "chats_list_tab"
135
+
136
+ self._on_message_handlers: list[
137
+ tuple[Callable[[Message], Any], Filter | None]
138
+ ] = []
139
+ self._on_message_edit_handlers: list[
140
+ tuple[Callable[[Message], Any], Filter | None]
141
+ ] = []
142
+ self._on_message_delete_handlers: list[
143
+ tuple[Callable[[Message], Any], Filter | None]
144
+ ] = []
145
+ self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
146
+
97
147
  self._ssl_context = ssl.create_default_context()
98
148
  self._ssl_context.set_ciphers("DEFAULT")
99
149
  self._ssl_context.check_hostname = True
@@ -101,6 +151,8 @@ class MaxClient(ApiMixin, WebSocketMixin):
101
151
  self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
102
152
  self._ssl_context.load_default_certs()
103
153
  self._socket: socket.socket | None = None
154
+ self._ws: websockets.ClientConnection | None = None
155
+
104
156
  self._setup_logger()
105
157
  self.logger.debug(
106
158
  "Initialized MaxClient uri=%s work_dir=%s",
@@ -109,13 +161,16 @@ class MaxClient(ApiMixin, WebSocketMixin):
109
161
  )
110
162
 
111
163
  def _setup_logger(self) -> None:
112
- if not logger.handlers:
164
+ if not self.logger.handlers:
165
+ if not self.logger.level:
166
+ self.logger.setLevel(logging.INFO)
113
167
  handler = logging.StreamHandler()
114
- formatter = logging.Formatter(
115
- "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
168
+ formatter = ColoredFormatter(
169
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
170
+ datefmt="%Y-%m-%d %H:%M:%S",
116
171
  )
117
172
  handler.setFormatter(formatter)
118
- logger.addHandler(handler)
173
+ self.logger.addHandler(handler)
119
174
 
120
175
  async def _wait_forever(self):
121
176
  try:
@@ -123,6 +178,18 @@ class MaxClient(ApiMixin, WebSocketMixin):
123
178
  except asyncio.CancelledError:
124
179
  self.logger.debug("wait_closed cancelled")
125
180
 
181
+ async def _safe_execute(self, coro, *, context: str = "unknown"):
182
+ """
183
+ Безопасно выполняет пользовательскую корутину.
184
+ Логирует traceback, но не роняет event loop.
185
+ """
186
+ try:
187
+ return await coro
188
+ except Exception as e:
189
+ self.logger.error(
190
+ f"Unhandled exception in {context}: {e}\n{traceback.format_exc()}"
191
+ )
192
+
126
193
  async def close(self) -> None:
127
194
  try:
128
195
  self.logger.info("Closing client")
@@ -145,44 +212,122 @@ class MaxClient(ApiMixin, WebSocketMixin):
145
212
  except Exception:
146
213
  self.logger.exception("Error closing client")
147
214
 
215
+ @override
216
+ def _create_safe_task(
217
+ self, coro: Awaitable[Any], *, name: str | None = None
218
+ ) -> asyncio.Task[Any | None]:
219
+ async def runner():
220
+ try:
221
+ return await coro
222
+ except asyncio.CancelledError:
223
+ raise
224
+ except Exception as e:
225
+ self.logger.exception(
226
+ f"Unhandled exception in task {name or coro}: {e}",
227
+ exc_info=e,
228
+ )
229
+ return None
230
+
231
+ task = asyncio.create_task(runner(), name=name)
232
+ self._background_tasks.add(task)
233
+ return task
234
+
148
235
  async def start(self) -> None:
149
236
  """
150
237
  Запускает клиент, подключается к WebSocket, авторизует
151
238
  пользователя (если нужно) и запускает фоновый цикл.
239
+ Теперь включает безопасный reconnect-loop, если self.reconnect=True.
152
240
  """
153
- try:
154
- self.logger.info("Client starting")
155
- await self._connect(self.user_agent)
156
-
157
- if self.registration:
158
- if not self.first_name:
159
- raise ValueError("First name is required for registration")
160
- await self._register(self.first_name, self.last_name)
161
-
162
- if self._token and self._database.get_auth_token() is None:
163
- self._database.update_auth_token(self._device_id, self._token)
164
-
165
- if self._token is None:
166
- await self._login()
167
- else:
168
- await self._sync()
169
-
170
- if self._on_start_handler:
171
- self.logger.debug("Calling on_start handler")
172
- result = self._on_start_handler()
173
- if asyncio.iscoroutine(result):
174
- await result
175
-
176
- ping_task = asyncio.create_task(self._send_interactive_ping())
177
- ping_task.add_done_callback(self._log_task_exception)
178
- self._background_tasks.add(ping_task)
179
- if self._send_fake_telemetry:
180
- telemetry_task = asyncio.create_task(self._start())
181
- telemetry_task.add_done_callback(self._log_task_exception)
182
- self._background_tasks.add(telemetry_task)
183
- await self._wait_forever()
184
- except Exception:
185
- self.logger.exception("Client start failed")
241
+
242
+ while True:
243
+ try:
244
+ self.logger.info("Client starting")
245
+ await self._connect(self.user_agent)
246
+
247
+ if self.registration:
248
+ if not self.first_name:
249
+ raise ValueError("First name is required for registration")
250
+ await self._register(self.first_name, self.last_name)
251
+
252
+ if self._token and self._database.get_auth_token() is None:
253
+ self._database.update_auth_token(self._device_id, self._token)
254
+
255
+ if self._token is None:
256
+ await self._login()
257
+ else:
258
+ await self._sync()
259
+
260
+ if self._on_start_handler:
261
+ self.logger.debug("Calling on_start handler")
262
+ result = self._on_start_handler()
263
+ if asyncio.iscoroutine(result):
264
+ await self._safe_execute(result, context="on_start handler")
265
+
266
+ ping_task = asyncio.create_task(self._send_interactive_ping())
267
+ ping_task.add_done_callback(self._log_task_exception)
268
+ self._background_tasks.add(ping_task)
269
+
270
+ if self._send_fake_telemetry:
271
+ telemetry_task = asyncio.create_task(self._start())
272
+ telemetry_task.add_done_callback(self._log_task_exception)
273
+ self._background_tasks.add(telemetry_task)
274
+
275
+ await self._wait_forever()
276
+ self.logger.info("WebSocket closed (wait_forever exited)")
277
+
278
+ except Exception:
279
+ self.logger.exception("Client start iteration failed")
280
+
281
+ finally:
282
+ self.logger.debug("Cleaning up background tasks and pending futures")
283
+
284
+ for task in list(self._background_tasks):
285
+ task.cancel()
286
+ try:
287
+ await task
288
+ except asyncio.CancelledError:
289
+ pass
290
+ except Exception:
291
+ self.logger.debug(
292
+ "Background task raised during cancellation", exc_info=True
293
+ )
294
+ self._background_tasks.discard(task)
295
+
296
+ if self._recv_task:
297
+ self._recv_task.cancel()
298
+ with contextlib.suppress(asyncio.CancelledError):
299
+ await self._recv_task
300
+ self._recv_task = None
301
+
302
+ if self._outgoing_task:
303
+ self._outgoing_task.cancel()
304
+ with contextlib.suppress(asyncio.CancelledError):
305
+ await self._outgoing_task
306
+ self._outgoing_task = None
307
+
308
+ for fut in self._pending.values():
309
+ if not fut.done():
310
+ fut.set_exception(WebSocketNotConnectedError)
311
+ self._pending.clear()
312
+
313
+ if self._ws:
314
+ try:
315
+ await self._ws.close()
316
+ except Exception:
317
+ self.logger.debug(
318
+ "Error closing ws during cleanup", exc_info=True
319
+ )
320
+ self._ws = None
321
+
322
+ self.is_connected = False
323
+ self.logger.info("Client start() cleaned up")
324
+
325
+ if not self.reconnect:
326
+ self.logger.info("Reconnect disabled — exiting start()")
327
+ return
328
+
329
+ self.logger.info("Reconnect enabled — restarting client")
330
+ await asyncio.sleep(self.reconnect_delay)
186
331
 
187
332
 
188
333
  class SocketMaxClient(SocketMixin, MaxClient):
pymax/exceptions.py CHANGED
@@ -39,15 +39,6 @@ class SocketSendError(Exception):
39
39
  super().__init__("Send and wait failed (socket)")
40
40
 
41
41
 
42
- class LoginError(Exception):
43
- """
44
- Исключение, вызываемое при ошибке авторизации.
45
- """
46
-
47
- def __init__(self, message: str) -> None:
48
- super().__init__(f"Login error: {message}")
49
-
50
-
51
42
  class ResponseError(Exception):
52
43
  """
53
44
  Исключение, вызываемое при ошибке в ответе от сервера.
@@ -64,3 +55,54 @@ class ResponseStructureError(Exception):
64
55
 
65
56
  def __init__(self, message: str) -> None:
66
57
  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)
pymax/files.py CHANGED
@@ -9,7 +9,9 @@ from typing_extensions import override
9
9
 
10
10
 
11
11
  class BaseFile(ABC):
12
- def __init__(self, url: str | None = None, path: str | None = None) -> None:
12
+ def __init__(
13
+ self, url: str | None = None, path: str | None = None
14
+ ) -> None:
13
15
  self.url = url
14
16
  self.path = path
15
17
 
@@ -45,7 +47,9 @@ class Photo(BaseFile):
45
47
  ".bmp",
46
48
  } # FIXME: костыль ✅
47
49
 
48
- def __init__(self, url: str | None = None, path: str | None = None) -> None:
50
+ def __init__(
51
+ self, url: str | None = None, path: str | None = None
52
+ ) -> None:
49
53
  super().__init__(url, path)
50
54
 
51
55
  def validate_photo(self) -> tuple[str, str] | None:
@@ -67,7 +71,9 @@ class Photo(BaseFile):
67
71
  mime_type = mimetypes.guess_type(self.url)[0]
68
72
 
69
73
  if not mime_type or not mime_type.startswith("image/"):
70
- raise ValueError(f"URL does not appear to be an image: {self.url}")
74
+ raise ValueError(
75
+ f"URL does not appear to be an image: {self.url}"
76
+ )
71
77
 
72
78
  return (extension[1:], mime_type)
73
79
  return None
@@ -84,7 +90,9 @@ class Video(BaseFile):
84
90
 
85
91
 
86
92
  class File(BaseFile):
87
- def __init__(self, url: str | None = None, path: str | None = None) -> None:
93
+ def __init__(
94
+ self, url: str | None = None, path: str | None = None
95
+ ) -> None:
88
96
  self.file_name: str = ""
89
97
  if path:
90
98
  self.file_name = Path(path).name
pymax/formatter.py ADDED
@@ -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
pymax/interfaces.py CHANGED
@@ -9,8 +9,6 @@ from typing import TYPE_CHECKING, Any, Literal
9
9
 
10
10
  import websockets
11
11
 
12
- from pymax.static.constant import DEFAULT_USER_AGENT
13
-
14
12
  from .filters import Filter
15
13
  from .payloads import UserAgentPayload
16
14
  from .static.constant import DEFAULT_TIMEOUT
@@ -43,7 +41,9 @@ class ClientProtocol(ABC):
43
41
  self.registration: bool
44
42
  self.first_name: str
45
43
  self.last_name: str | None
44
+ self._token: str | None
46
45
  self._work_dir: str
46
+ self.reconnect: bool
47
47
  self._database_path: Path
48
48
  self._ws: websockets.ClientConnection | None = None
49
49
  self._seq: int = 0
@@ -57,7 +57,6 @@ class ClientProtocol(ABC):
57
57
  self._error_count: int = 0
58
58
  self._circuit_breaker: bool = False
59
59
  self._last_error_time: float = 0.0
60
- self.user_agent = DEFAULT_USER_AGENT
61
60
  self._session_id: int
62
61
  self._action_id: int = 0
63
62
  self._current_screen: str = "chats_list_tab"
@@ -99,3 +98,9 @@ class ClientProtocol(ABC):
99
98
  max_retries: int = 3,
100
99
  ) -> Message | None:
101
100
  pass
101
+
102
+ @abstractmethod
103
+ def _create_safe_task(
104
+ self, coro: Awaitable[Any], name: str | None = None
105
+ ) -> asyncio.Task[Any]:
106
+ pass