maxapi-python 1.1.21__tar.gz → 1.2.1__tar.gz

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.
Files changed (66) hide show
  1. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/PKG-INFO +8 -1
  2. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/README.md +6 -0
  3. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/examples/example.py +2 -6
  4. maxapi_python-1.2.1/examples/test.py +20 -0
  5. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/pyproject.toml +2 -1
  6. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/core.py +14 -152
  7. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/crud.py +1 -1
  8. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/formatting.py +4 -6
  9. maxapi_python-1.2.1/src/pymax/interfaces.py +256 -0
  10. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/auth.py +162 -20
  11. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/socket.py +30 -43
  12. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/static/constant.py +1 -1
  13. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/static/enum.py +4 -0
  14. maxapi_python-1.1.21/src/pymax/interfaces.py +0 -122
  15. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.coderabbit.yaml +0 -0
  16. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.github/FUNDING.yml +0 -0
  17. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  18. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  19. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.github/ISSUE_TEMPLATE/refactor.md +0 -0
  20. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.github/pull_request_template.md +0 -0
  21. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.github/workflows/publish.yml +0 -0
  22. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.gitignore +0 -0
  23. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.pre-commit-config.yaml +0 -0
  24. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/LICENSE +0 -0
  25. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/assets/icon.svg +0 -0
  26. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/assets/logo.svg +0 -0
  27. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/examples/flt_test.py +0 -0
  28. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/examples/large_file_upload.py +0 -0
  29. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/examples/reg.py +0 -0
  30. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/examples/telegram_bridge.py +0 -0
  31. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/mkdocs.yml +0 -0
  32. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/pytest.ini +0 -0
  33. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/Makefile +0 -0
  34. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/build.sh +0 -0
  35. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/make.bat +0 -0
  36. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/_static/logo.svg +0 -0
  37. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/clients.rst +0 -0
  38. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/conf.py +0 -0
  39. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/decorators.rst +0 -0
  40. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/examples.rst +0 -0
  41. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/guides.rst +0 -0
  42. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/index.rst +0 -0
  43. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/installation.rst +0 -0
  44. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/quickstart.rst +0 -0
  45. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/types.rst +0 -0
  46. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/ruff.toml +0 -0
  47. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/__init__.py +0 -0
  48. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/exceptions.py +0 -0
  49. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/files.py +0 -0
  50. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/filters.py +0 -0
  51. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/formatter.py +0 -0
  52. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/__init__.py +0 -0
  53. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/channel.py +0 -0
  54. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/group.py +0 -0
  55. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/handler.py +0 -0
  56. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/message.py +0 -0
  57. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/scheduler.py +0 -0
  58. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/self.py +0 -0
  59. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/telemetry.py +0 -0
  60. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/user.py +0 -0
  61. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/utils.py +0 -0
  62. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/websocket.py +0 -0
  63. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/models.py +0 -0
  64. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/navigation.py +0 -0
  65. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/payloads.py +0 -0
  66. {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxapi-python
3
- Version: 1.1.21
3
+ Version: 1.2.1
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
@@ -16,6 +16,7 @@ Requires-Dist: aiofiles>=24.1.0
16
16
  Requires-Dist: aiohttp>=3.12.15
17
17
  Requires-Dist: lz4>=4.4.4
18
18
  Requires-Dist: msgpack>=1.1.1
19
+ Requires-Dist: qrcode>=8.2
19
20
  Requires-Dist: sqlmodel>=0.0.24
20
21
  Requires-Dist: websockets>=15.0
21
22
  Description-Content-Type: text/markdown
@@ -35,6 +36,12 @@ Description-Content-Type: text/markdown
35
36
  <img src="https://img.shields.io/badge/packaging-uv-D7FF64.svg" alt="Packaging">
36
37
  </p>
37
38
 
39
+ > [!IMPORTANT]
40
+ > (20.12.25) Из за резкого изменения апи большая часть библиотеки не работает.
41
+ Смотрите [новость](https://t.me/pymax_news/111)
42
+ >
43
+ > P.s добавил логин по qr в dev/1.2.1
44
+
38
45
  ---
39
46
  > ⚠️ **Дисклеймер**
40
47
  >
@@ -13,6 +13,12 @@
13
13
  <img src="https://img.shields.io/badge/packaging-uv-D7FF64.svg" alt="Packaging">
14
14
  </p>
15
15
 
16
+ > [!IMPORTANT]
17
+ > (20.12.25) Из за резкого изменения апи большая часть библиотеки не работает.
18
+ Смотрите [новость](https://t.me/pymax_news/111)
19
+ >
20
+ > P.s добавил логин по qr в dev/1.2.1
21
+
16
22
  ---
17
23
  > ⚠️ **Дисклеймер**
18
24
  >
@@ -44,9 +44,7 @@ async def handle_start() -> None:
44
44
  messages = []
45
45
  from_time = int(time() * 1000)
46
46
  while len(messages) < max_messages:
47
- r = await client.fetch_history(
48
- chat_id=chat_id, from_time=from_time, backward=30
49
- )
47
+ r = await client.fetch_history(chat_id=chat_id, from_time=from_time, backward=30)
50
48
  if not r:
51
49
  break
52
50
  from_time = r[0].time
@@ -131,9 +129,7 @@ async def handle_start() -> None:
131
129
 
132
130
  @client.on_message()
133
131
  async def handle_message(message: Message) -> None:
134
- print(
135
- f"New message in chat {message.chat_id} from {message.sender}: {message.text}"
136
- )
132
+ print(f"New message in chat {message.chat_id} from {message.sender}: {message.text}")
137
133
  # if message.link and message.link.message.attaches:
138
134
  # for attach in message.link.message.attaches:
139
135
  # print(f"Link attach type: {attach.type}")
@@ -0,0 +1,20 @@
1
+ import asyncio
2
+
3
+ from pymax import MaxClient
4
+ from pymax.payloads import UserAgentPayload
5
+
6
+ ua = UserAgentPayload(device_type="WEB")
7
+
8
+ client = MaxClient(
9
+ phone="+79911111111",
10
+ work_dir="cache",
11
+ headers=ua,
12
+ )
13
+
14
+
15
+ @client.on_start
16
+ async def on_start() -> None:
17
+ print(f"MaxClient started as {client.me.names[0].first_name}!")
18
+
19
+
20
+ asyncio.run(client.start())
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "maxapi-python"
3
- version = "1.1.21"
3
+ version = "1.2.1"
4
4
  description = "Python wrapper для API мессенджера Max"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -18,6 +18,7 @@ dependencies = [
18
18
  "lz4>=4.4.4",
19
19
  "aiohttp>=3.12.15",
20
20
  "aiofiles>=24.1.0",
21
+ "qrcode>=8.2",
21
22
  ]
22
23
 
23
24
  [project.urls]
@@ -6,21 +6,19 @@ import logging
6
6
  import socket
7
7
  import ssl
8
8
  import time
9
- import traceback
10
9
  from collections.abc import Awaitable
11
10
  from pathlib import Path
12
11
  from typing import TYPE_CHECKING, Any, Literal
13
12
  from uuid import UUID
14
13
 
15
- from typing_extensions import Self, override
14
+ from typing_extensions import override
16
15
 
17
16
  from .crud import Database
18
17
  from .exceptions import (
19
18
  InvalidPhoneError,
20
19
  SocketNotConnectedError,
21
- WebSocketNotConnectedError,
22
20
  )
23
- from .formatter import ColoredFormatter
21
+ from .interfaces import BaseClient
24
22
  from .mixins import ApiMixin, SocketMixin, WebSocketMixin
25
23
  from .payloads import UserAgentPayload
26
24
  from .static.constant import (
@@ -43,7 +41,7 @@ if TYPE_CHECKING:
43
41
  logger = logging.getLogger(__name__)
44
42
 
45
43
 
46
- class MaxClient(ApiMixin, WebSocketMixin):
44
+ class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
47
45
  """
48
46
  Основной клиент для работы с WebSocket API сервиса Max.
49
47
 
@@ -139,9 +137,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
139
137
  self._circuit_breaker: bool = False
140
138
  self._last_error_time: float = 0.0
141
139
 
142
- self._device_id = (
143
- device_id if device_id is not None else self._database.get_device_id()
144
- )
140
+ self._device_id = device_id if device_id is not None else self._database.get_device_id()
145
141
  self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
146
142
 
147
143
  self._token = self._database.get_auth_token() or token
@@ -161,16 +157,11 @@ class MaxClient(ApiMixin, WebSocketMixin):
161
157
  tuple[Callable[[Message], Any], BaseFilter[Message] | None]
162
158
  ] = []
163
159
  self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
164
- self._on_reaction_change_handlers: list[
165
- tuple[Callable[[str, int, ReactionInfo], Any]]
166
- ] = []
167
- self._on_chat_update_handlers: list[tuple[Callable[[Chat], Any]]] = []
168
- self._on_raw_receive_handlers: list[
169
- Callable[[dict[str, Any]], Any | Awaitable[Any]]
170
- ] = []
171
- self._scheduled_tasks: list[
172
- tuple[Callable[[], Any | Awaitable[Any]], float]
173
- ] = []
160
+ self._on_stop_handler: Callable[[], Any | Awaitable[Any]] | None = None
161
+ self._on_reaction_change_handlers: list[Callable[[str, int, ReactionInfo], Any]] = []
162
+ self._on_chat_update_handlers: list[Callable[[Chat], Any | Awaitable[Any]]] = []
163
+ self._on_raw_receive_handlers: list[Callable[[dict[str, Any]], Any | Awaitable[Any]]] = []
164
+ self._scheduled_tasks: list[tuple[Callable[[], Any | Awaitable[Any]], float]] = []
174
165
 
175
166
  self._ssl_context = ssl.create_default_context()
176
167
  self._ssl_context.set_ciphers("DEFAULT")
@@ -188,36 +179,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
188
179
  self._work_dir,
189
180
  )
190
181
 
191
- def _setup_logger(self) -> None:
192
- if not self.logger.handlers:
193
- if not self.logger.level:
194
- self.logger.setLevel(logging.INFO)
195
- handler = logging.StreamHandler()
196
- formatter = ColoredFormatter(
197
- "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
198
- datefmt="%Y-%m-%d %H:%M:%S",
199
- )
200
- handler.setFormatter(formatter)
201
- self.logger.addHandler(handler)
202
-
203
182
  async def _wait_forever(self) -> None:
204
183
  try:
205
184
  await self.ws.wait_closed()
206
185
  except asyncio.CancelledError:
207
186
  self.logger.debug("wait_closed cancelled")
208
187
 
209
- async def _safe_execute(self, coro, *, context: str = "unknown") -> Any:
210
- """
211
- Безопасно выполняет пользовательскую корутину.
212
- Логирует traceback, но не роняет event loop.
213
- """
214
- try:
215
- return await coro
216
- except Exception as e:
217
- self.logger.error(
218
- f"Unhandled exception in {context}: {e}\n{traceback.format_exc()}"
219
- )
220
-
221
188
  async def close(self) -> None:
222
189
  """
223
190
  Закрывает клиент и освобождает ресурсы.
@@ -245,26 +212,6 @@ class MaxClient(ApiMixin, WebSocketMixin):
245
212
  except Exception:
246
213
  self.logger.exception("Error closing client")
247
214
 
248
- @override
249
- def _create_safe_task(
250
- self, coro: Awaitable[Any], *, name: str | None = None
251
- ) -> asyncio.Task[Any | None]:
252
- async def runner():
253
- try:
254
- return await coro
255
- except asyncio.CancelledError:
256
- raise
257
- except Exception as e:
258
- tb = traceback.format_exc()
259
- self.logger.error(
260
- f"Unhandled exception in task {name or coro}: {e}\n{tb}"
261
- )
262
- raise
263
-
264
- task = asyncio.create_task(runner(), name=name)
265
- self._background_tasks.add(task)
266
- return task
267
-
268
215
  async def _post_login_tasks(self, sync: bool = True) -> None:
269
216
  if sync:
270
217
  await self._sync()
@@ -288,49 +235,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
288
235
  if asyncio.iscoroutine(result):
289
236
  await self._safe_execute(result, context="on_start handler")
290
237
 
291
- async def _cleanup_client(self) -> None:
292
- for task in list(self._background_tasks):
293
- task.cancel()
294
- try:
295
- await task
296
- except asyncio.CancelledError:
297
- pass
298
- except Exception:
299
- self.logger.debug(
300
- "Background task raised during cancellation", exc_info=True
301
- )
302
- self._background_tasks.discard(task)
303
-
304
- if self._recv_task:
305
- self._recv_task.cancel()
306
- with contextlib.suppress(asyncio.CancelledError):
307
- await self._recv_task
308
- self._recv_task = None
309
-
310
- if self._outgoing_task:
311
- self._outgoing_task.cancel()
312
- with contextlib.suppress(asyncio.CancelledError):
313
- await self._outgoing_task
314
- self._outgoing_task = None
315
-
316
- for fut in self._pending.values():
317
- if not fut.done():
318
- fut.set_exception(WebSocketNotConnectedError)
319
- self._pending.clear()
320
-
321
- if self._ws:
322
- try:
323
- await self._ws.close()
324
- except Exception:
325
- self.logger.debug("Error closing ws during cleanup", exc_info=True)
326
- self._ws = None
327
-
328
- self.is_connected = False
329
- self.logger.info("Client start() cleaned up")
330
-
331
- async def login_with_code(
332
- self, temp_token: str, code: str, start: bool = False
333
- ) -> None:
238
+ async def login_with_code(self, temp_token: str, code: str, start: bool = False) -> None:
334
239
  """
335
240
  Завершает кастомный login flow: отправляет код, сохраняет токен и запускает пост-логин задачи.
336
241
 
@@ -348,7 +253,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
348
253
  if not token:
349
254
  raise ValueError("Login response did not contain tokenAttrs.LOGIN.token")
350
255
  self._token = token
351
- self._database.update_auth_token(str(self._device_id), token)
256
+ self._database.update_auth_token(self._device_id, token)
352
257
  if start:
353
258
  while True:
354
259
  try:
@@ -385,12 +290,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
385
290
  await self._register(self.first_name, self.last_name)
386
291
 
387
292
  if self._token and self._database.get_auth_token() is None:
388
- self._database.update_auth_token(str(self._device_id), self._token)
293
+ self._database.update_auth_token(self._device_id, self._token)
389
294
 
390
295
  if self._token is None:
391
296
  await self._login()
392
- else:
393
- await self._sync()
297
+
298
+ await self._sync()
394
299
 
395
300
  await self._post_login_tasks(sync=False)
396
301
 
@@ -412,43 +317,6 @@ class MaxClient(ApiMixin, WebSocketMixin):
412
317
  self.logger.info("Reconnect enabled — restarting client")
413
318
  await asyncio.sleep(self.reconnect_delay)
414
319
 
415
- async def idle(self):
416
- """
417
- Поддерживает клиента в «ожидающем» состоянии до закрытия клиента или иного прерывающего события.
418
-
419
- :return: Никогда не возвращает значение; функция блокирует выполнение.
420
- :rtype: None
421
- """
422
- await asyncio.Event().wait()
423
-
424
- def inspect(self) -> None:
425
- """
426
- Выводит в лог текущий статус клиента для отладки.
427
- """
428
- self.logger.info("Pymax")
429
- self.logger.info("---------")
430
- self.logger.info(f"Connected: {self.is_connected}")
431
- if self.me is not None:
432
- self.logger.info(f"Me: {self.me.names[0].first_name} ({self.me.id})")
433
- else:
434
- self.logger.info("Me: N/A")
435
- self.logger.info(f"Dialogs: {len(self.dialogs)}")
436
- self.logger.info(f"Chats: {len(self.chats)}")
437
- self.logger.info(f"Channels: {len(self.channels)}")
438
- self.logger.info(f"Users cached: {len(self._users)}")
439
- self.logger.info(f"Background tasks: {len(self._background_tasks)}")
440
- self.logger.info(f"Scheduled tasks: {len(self._scheduled_tasks)}")
441
- self.logger.info("---------")
442
-
443
- async def __aenter__(self) -> Self:
444
- self._create_safe_task(self.start(), name="start")
445
- while not self.is_connected:
446
- await asyncio.sleep(0.05)
447
- return self
448
-
449
- async def __aexit__(self, exc_type, exc, tb) -> None:
450
- await self.close()
451
-
452
320
 
453
321
  class SocketMaxClient(SocketMixin, MaxClient):
454
322
  @override
@@ -463,12 +331,6 @@ class SocketMaxClient(SocketMixin, MaxClient):
463
331
 
464
332
  @override
465
333
  async def _cleanup_client(self):
466
- """
467
- Socket-specific cleanup: cancel background tasks, set pending futures
468
- exceptions to SocketNotConnectedError, and close socket.
469
- """
470
- from .exceptions import SocketNotConnectedError
471
-
472
334
  for task in list(self._background_tasks):
473
335
  task.cancel()
474
336
  try:
@@ -48,7 +48,7 @@ class Database:
48
48
  session.refresh(auth)
49
49
  return auth
50
50
 
51
- def update_auth_token(self, device_id: str, token: str) -> None:
51
+ def update_auth_token(self, device_id: UUID, token: str) -> None:
52
52
  with self.get_session() as session:
53
53
  auth = session.exec(select(Auth).where(Auth.device_id == device_id)).first()
54
54
  if auth:
@@ -46,14 +46,12 @@ class Formatting:
46
46
 
47
47
  if inner_text is not None and fmt_type is not None:
48
48
  next_pos = match.end()
49
- has_newline = (
50
- next_pos < len(text) and text[next_pos] == "\n"
51
- ) or (next_pos == len(text))
49
+ has_newline = (next_pos < len(text) and text[next_pos] == "\n") or (
50
+ next_pos == len(text)
51
+ )
52
52
 
53
53
  length = len(inner_text) + (1 if has_newline else 0)
54
- elements.append(
55
- Element(type=fmt_type, from_=current_pos, length=length)
56
- )
54
+ elements.append(Element(type=fmt_type, from_=current_pos, length=length))
57
55
 
58
56
  clean_parts.append(inner_text)
59
57
  if has_newline:
@@ -0,0 +1,256 @@
1
+ import asyncio
2
+ import contextlib
3
+ import logging
4
+ import socket
5
+ import ssl
6
+ import traceback
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import Awaitable, Callable
9
+ from logging import Logger
10
+ from typing import TYPE_CHECKING, Any, Literal
11
+
12
+ from typing_extensions import Self
13
+
14
+ from pymax.exceptions import WebSocketNotConnectedError
15
+ from pymax.formatter import ColoredFormatter
16
+
17
+ from .payloads import UserAgentPayload
18
+ from .static.constant import DEFAULT_TIMEOUT
19
+ from .static.enum import Opcode
20
+ from .types import Channel, Chat, Dialog, Me, Message, User
21
+
22
+ if TYPE_CHECKING:
23
+ from pathlib import Path
24
+ from uuid import UUID
25
+
26
+ import websockets
27
+
28
+ from pymax import AttachType
29
+ from pymax.types import ReactionInfo
30
+
31
+ from .crud import Database
32
+ from .filters import BaseFilter
33
+
34
+
35
+ class ClientProtocol(ABC):
36
+ def __init__(self, logger: Logger) -> None:
37
+ super().__init__()
38
+ self.logger = logger
39
+ self._users: dict[int, User] = {}
40
+ self.chats: list[Chat] = []
41
+ self._database: Database
42
+ self._device_id: UUID
43
+ self.uri: str
44
+ self.is_connected: bool = False
45
+ self.phone: str
46
+ self.dialogs: list[Dialog] = []
47
+ self.channels: list[Channel] = []
48
+ self.me: Me | None = None
49
+ self.host: str
50
+ self.port: int
51
+ self.proxy: str | Literal[True] | None
52
+ self.registration: bool
53
+ self.first_name: str
54
+ self.last_name: str | None
55
+ self._token: str | None
56
+ self._work_dir: str
57
+ self.reconnect: bool
58
+ self._database_path: Path
59
+ self._ws: websockets.ClientConnection | None = None
60
+ self._seq: int = 0
61
+ self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
62
+ self._recv_task: asyncio.Task[Any] | None = None
63
+ self._incoming: asyncio.Queue[dict[str, Any]] | None = None
64
+ self._file_upload_waiters: dict[
65
+ int,
66
+ asyncio.Future[dict[str, Any]],
67
+ ] = {}
68
+ self.user_agent = UserAgentPayload()
69
+ self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
70
+ self._outgoing_task: asyncio.Task[Any] | None = None
71
+ self._error_count: int = 0
72
+ self._circuit_breaker: bool = False
73
+ self._last_error_time: float = 0.0
74
+ self._session_id: int
75
+ self._action_id: int = 0
76
+ self._current_screen: str = "chats_list_tab"
77
+ self._on_message_handlers: list[
78
+ tuple[Callable[[Message], Any], BaseFilter[Message] | None]
79
+ ] = []
80
+ self._on_message_edit_handlers: list[
81
+ tuple[Callable[[Message], Any], BaseFilter[Message] | None]
82
+ ] = []
83
+ self._on_message_delete_handlers: list[
84
+ tuple[Callable[[Message], Any], BaseFilter[Message] | None]
85
+ ] = []
86
+ self._on_reaction_change_handlers: list[Callable[[str, int, ReactionInfo], Any]] = []
87
+ self._on_chat_update_handlers: list[Callable[[Chat], Any | Awaitable[Any]]] = []
88
+ self._on_raw_receive_handlers: list[Callable[[dict[str, Any]], Any | Awaitable[Any]]] = []
89
+ self._scheduled_tasks: list[tuple[Callable[[], Any | Awaitable[Any]], float]] = []
90
+ self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
91
+ self._background_tasks: set[asyncio.Task[Any]] = set()
92
+ self._ssl_context: ssl.SSLContext
93
+ self._socket: socket.socket | None = None
94
+
95
+ @abstractmethod
96
+ async def _send_and_wait(
97
+ self,
98
+ opcode: Opcode,
99
+ payload: dict[str, Any],
100
+ cmd: int = 0,
101
+ timeout: float = DEFAULT_TIMEOUT,
102
+ ) -> dict[str, Any]:
103
+ pass
104
+
105
+ @abstractmethod
106
+ async def _get_chat(self, chat_id: int) -> Chat | None:
107
+ pass
108
+
109
+ @abstractmethod
110
+ async def _queue_message(
111
+ self,
112
+ opcode: int,
113
+ payload: dict[str, Any],
114
+ cmd: int = 0,
115
+ timeout: float = DEFAULT_TIMEOUT,
116
+ max_retries: int = 3,
117
+ ) -> Message | None:
118
+ pass
119
+
120
+ @abstractmethod
121
+ def _create_safe_task(
122
+ self, coro: Awaitable[Any], name: str | None = None
123
+ ) -> asyncio.Task[Any]:
124
+ pass
125
+
126
+
127
+ class BaseClient(ClientProtocol):
128
+ def _setup_logger(self) -> None:
129
+ if not self.logger.handlers:
130
+ if not self.logger.level:
131
+ self.logger.setLevel(logging.INFO)
132
+ handler = logging.StreamHandler()
133
+ formatter = ColoredFormatter(
134
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
135
+ datefmt="%Y-%m-%d %H:%M:%S",
136
+ )
137
+ handler.setFormatter(formatter)
138
+ self.logger.addHandler(handler)
139
+
140
+ async def _safe_execute(self, coro, *, context: str = "unknown") -> Any:
141
+ try:
142
+ return await coro
143
+ except Exception as e:
144
+ self.logger.error(f"Unhandled exception in {context}: {e}\n{traceback.format_exc()}")
145
+
146
+ def _create_safe_task(
147
+ self, coro: Awaitable[Any], name: str | None = None
148
+ ) -> asyncio.Task[Any | None]:
149
+ async def runner():
150
+ try:
151
+ return await coro
152
+ except asyncio.CancelledError:
153
+ raise
154
+ except Exception as e:
155
+ tb = traceback.format_exc()
156
+ self.logger.error(f"Unhandled exception in task {name or coro}: {e}\n{tb}")
157
+ raise
158
+
159
+ task = asyncio.create_task(runner(), name=name)
160
+ self._background_tasks.add(task)
161
+ return task
162
+
163
+ async def _cleanup_client(self) -> None:
164
+ for task in list(self._background_tasks):
165
+ task.cancel()
166
+ try:
167
+ await task
168
+ except asyncio.CancelledError:
169
+ pass
170
+ except Exception:
171
+ self.logger.debug("Background task raised during cancellation", exc_info=True)
172
+ self._background_tasks.discard(task)
173
+
174
+ if self._recv_task:
175
+ self._recv_task.cancel()
176
+ with contextlib.suppress(asyncio.CancelledError):
177
+ await self._recv_task
178
+ self._recv_task = None
179
+
180
+ if self._outgoing_task:
181
+ self._outgoing_task.cancel()
182
+ with contextlib.suppress(asyncio.CancelledError):
183
+ await self._outgoing_task
184
+ self._outgoing_task = None
185
+
186
+ for fut in self._pending.values():
187
+ if not fut.done():
188
+ fut.set_exception(WebSocketNotConnectedError())
189
+ self._pending.clear()
190
+
191
+ if self._ws:
192
+ try:
193
+ await self._ws.close()
194
+ except Exception:
195
+ self.logger.debug("Error closing ws during cleanup", exc_info=True)
196
+ self._ws = None
197
+
198
+ self.is_connected = False
199
+ self.logger.info("Client start() cleaned up")
200
+
201
+ async def idle(self):
202
+ """
203
+ Поддерживает клиента в «ожидающем» состоянии до закрытия клиента или иного прерывающего события.
204
+
205
+ :return: Никогда не возвращает значение; функция блокирует выполнение.
206
+ :rtype: None
207
+ """
208
+ await asyncio.Event().wait()
209
+
210
+ def inspect(self) -> None:
211
+ """
212
+ Выводит в лог текущий статус клиента для отладки.
213
+ """
214
+ self.logger.info("Pymax")
215
+ self.logger.info("---------")
216
+ self.logger.info(f"Connected: {self.is_connected}")
217
+ if self.me is not None:
218
+ self.logger.info(f"Me: {self.me.names[0].first_name} ({self.me.id})")
219
+ else:
220
+ self.logger.info("Me: N/A")
221
+ self.logger.info(f"Dialogs: {len(self.dialogs)}")
222
+ self.logger.info(f"Chats: {len(self.chats)}")
223
+ self.logger.info(f"Channels: {len(self.channels)}")
224
+ self.logger.info(f"Users cached: {len(self._users)}")
225
+ self.logger.info(f"Background tasks: {len(self._background_tasks)}")
226
+ self.logger.info(f"Scheduled tasks: {len(self._scheduled_tasks)}")
227
+ self.logger.info("---------")
228
+
229
+ async def __aenter__(self) -> Self:
230
+ self._create_safe_task(self.start(), name="start")
231
+ while not self.is_connected:
232
+ await asyncio.sleep(0.05)
233
+ return self
234
+
235
+ async def __aexit__(self, exc_type, exc, tb) -> None:
236
+ await self.close()
237
+
238
+ @abstractmethod
239
+ async def login_with_code(self, temp_token: str, code: str, start: bool = False) -> None:
240
+ pass
241
+
242
+ @abstractmethod
243
+ async def _post_login_tasks(self, sync: bool = True) -> None:
244
+ pass
245
+
246
+ @abstractmethod
247
+ async def _wait_forever(self) -> None:
248
+ pass
249
+
250
+ @abstractmethod
251
+ async def start(self) -> None:
252
+ pass
253
+
254
+ @abstractmethod
255
+ async def close(self) -> None:
256
+ pass