maxapi-python 1.1.15__tar.gz → 1.1.17__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 (57) hide show
  1. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/.gitignore +2 -0
  2. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/PKG-INFO +7 -3
  3. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/README.md +6 -2
  4. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/examples/example.py +24 -17
  5. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/examples/telegram_bridge.py +13 -5
  6. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/pyproject.toml +1 -1
  7. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/__init__.py +55 -1
  8. maxapi_python-1.1.17/src/pymax/core.py +342 -0
  9. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/exceptions.py +51 -9
  10. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/files.py +12 -4
  11. maxapi_python-1.1.17/src/pymax/formatter.py +30 -0
  12. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/interfaces.py +8 -3
  13. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/mixins/auth.py +12 -7
  14. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/mixins/channel.py +31 -13
  15. maxapi_python-1.1.17/src/pymax/mixins/group.py +260 -0
  16. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/mixins/handler.py +12 -8
  17. maxapi_python-1.1.17/src/pymax/mixins/message.py +672 -0
  18. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/mixins/self.py +7 -6
  19. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/mixins/socket.py +59 -82
  20. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/mixins/telemetry.py +7 -12
  21. maxapi_python-1.1.17/src/pymax/mixins/user.py +200 -0
  22. maxapi_python-1.1.17/src/pymax/mixins/utils.py +27 -0
  23. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/mixins/websocket.py +19 -14
  24. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/payloads.py +7 -2
  25. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/static/enum.py +18 -12
  26. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/types.py +56 -22
  27. maxapi_python-1.1.15/src/pymax/core.py +0 -197
  28. maxapi_python-1.1.15/src/pymax/mixins/group.py +0 -310
  29. maxapi_python-1.1.15/src/pymax/mixins/message.py +0 -689
  30. maxapi_python-1.1.15/src/pymax/mixins/user.py +0 -155
  31. maxapi_python-1.1.15/src/pymax/utils.py +0 -46
  32. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/.github/FUNDING.yml +0 -0
  33. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  34. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  35. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/.github/ISSUE_TEMPLATE/refactor.md +0 -0
  36. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/.github/pull_request_template.md +0 -0
  37. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/.github/workflows/publish.yml +0 -0
  38. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/.pre-commit-config.yaml +0 -0
  39. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/LICENSE +0 -0
  40. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/assets/icon.svg +0 -0
  41. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/assets/logo.svg +0 -0
  42. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/docs/api.md +0 -0
  43. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/docs/assets/icon.svg +0 -0
  44. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/docs/client.md +0 -0
  45. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/docs/examples.md +0 -0
  46. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/docs/index.md +0 -0
  47. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/docs/methods.md +0 -0
  48. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/docs/types.md +0 -0
  49. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/mkdocs.yml +0 -0
  50. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/ruff.toml +0 -0
  51. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/crud.py +0 -0
  52. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/filters.py +0 -0
  53. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/formatting.py +0 -0
  54. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/mixins/__init__.py +0 -0
  55. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/models.py +0 -0
  56. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/navigation.py +0 -0
  57. {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/static/constant.py +0 -0
@@ -103,6 +103,7 @@ Thumbs.db
103
103
  session.db
104
104
  *.bak
105
105
  *.swp
106
+ *.bin
106
107
 
107
108
  # Environment / secrets
108
109
  .env
@@ -114,6 +115,7 @@ cache/
114
115
 
115
116
  # Keep lockfiles and important configs tracked? If you want to track specific lockfiles,
116
117
  # remove them from this .gitignore (for example: remove poetry.lock or uv.lock).
118
+ tests2/
117
119
  tests/
118
120
 
119
121
 
@@ -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
 
@@ -133,10 +133,14 @@ if __name__ == "__main__":
133
133
 
134
134
  [Telegram](https://t.me/pymax_news)
135
135
 
136
- ## Авторы
136
+ ## Star History
137
+
138
+ [![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)
137
139
 
138
- - **[ink-developer](https://github.com/ink-developer)** — Оригинальный автор проекта
140
+ ## Авторы
139
141
  - **[ink](https://github.com/ink-developer)** — Главный разработчик, исследование API и его документация
142
+ - **[noxzion](https://github.com/noxzion)** — Оригинальный автор проекта
143
+
140
144
 
141
145
  ## Контрибьюторы
142
146
 
@@ -30,23 +30,30 @@ async def handle_deleted_message(message: Message) -> None:
30
30
  @client.on_start
31
31
  async def handle_start() -> None:
32
32
  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}")
43
- # history = await client.fetch_history(chat_id=0)
44
- # if history:
45
- # for message in history:
46
- # if message.attaches:
47
- # for attach in message.attaches:
48
- # if attach.type == AttachType.STICKER:
49
- # print(attach.lottie_url)
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}")
50
+ history = await client.fetch_history(chat_id=0)
51
+ if history:
52
+ for message in history:
53
+ if message.attaches:
54
+ for attach in message.attaches:
55
+ if attach.type == AttachType.AUDIO:
56
+ print(attach.url)
50
57
  # chat = await client.rework_invite_link(chat_id=0)
51
58
  # print(chat.link)
52
59
  # text = """
@@ -75,14 +75,17 @@ async def handle_message(message: Message) -> None:
75
75
  async with session.get(video.url) as response:
76
76
  response.raise_for_status() # Проверка на ошибки HTTP
77
77
  video_bytes = BytesIO(await response.read())
78
- video_bytes.name = response.headers.get("X-File-Name")
78
+ video_bytes.name = response.headers.get(
79
+ "X-File-Name"
80
+ )
79
81
 
80
82
  # Отправляем видео через телеграм бота
81
83
  await telegram_bot.send_video(
82
84
  chat_id=tg_id,
83
85
  caption=f"{sender.names[0].name}: {message.text}",
84
86
  video=types.BufferedInputFile(
85
- video_bytes.getvalue(), filename=video_bytes.name
87
+ video_bytes.getvalue(),
88
+ filename=video_bytes.name,
86
89
  ),
87
90
  )
88
91
 
@@ -102,14 +105,17 @@ async def handle_message(message: Message) -> None:
102
105
  async with session.get(attach.base_url) as response:
103
106
  response.raise_for_status() # Проверка на ошибки HTTP
104
107
  photo_bytes = BytesIO(await response.read())
105
- photo_bytes.name = response.headers.get("X-File-Name")
108
+ photo_bytes.name = response.headers.get(
109
+ "X-File-Name"
110
+ )
106
111
 
107
112
  # Отправляем фото через телеграм бота
108
113
  await telegram_bot.send_photo(
109
114
  chat_id=tg_id,
110
115
  caption=f"{sender.names[0].name}: {message.text}",
111
116
  photo=types.BufferedInputFile(
112
- photo_bytes.getvalue(), filename=photo_bytes.name
117
+ photo_bytes.getvalue(),
118
+ filename=photo_bytes.name,
113
119
  ),
114
120
  )
115
121
 
@@ -136,7 +142,9 @@ async def handle_message(message: Message) -> None:
136
142
  async with session.get(file.url) as response:
137
143
  response.raise_for_status() # Проверка на ошибки HTTP
138
144
  file_bytes = BytesIO(await response.read())
139
- file_bytes.name = response.headers.get("X-File-Name")
145
+ file_bytes.name = response.headers.get(
146
+ "X-File-Name"
147
+ )
140
148
 
141
149
  # Отправляем файл через телеграм бота
142
150
  await telegram_bot.send_document(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "maxapi-python"
3
- version = "1.1.15"
3
+ version = "1.1.17"
4
4
  description = "Python wrapper для API мессенджера Max"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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
  ]
@@ -0,0 +1,342 @@
1
+ import asyncio
2
+ import contextlib
3
+ import logging
4
+ import socket
5
+ import ssl
6
+ import time
7
+ import traceback
8
+ from collections.abc import Awaitable
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any, Literal
11
+
12
+ from typing_extensions import override
13
+
14
+ from .crud import Database
15
+ from .exceptions import InvalidPhoneError, WebSocketNotConnectedError
16
+ from .formatter import ColoredFormatter
17
+ from .mixins import ApiMixin, SocketMixin, WebSocketMixin
18
+ from .payloads import UserAgentPayload
19
+ from .static.constant import (
20
+ HOST,
21
+ PORT,
22
+ WEBSOCKET_URI,
23
+ )
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
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ class MaxClient(ApiMixin, WebSocketMixin):
39
+ """
40
+ Основной клиент для работы с WebSocket API сервиса Max.
41
+
42
+
43
+ Args:
44
+ phone (str): Номер телефона для авторизации.
45
+ uri (str, optional): URI WebSocket сервера. По умолчанию Constants.WEBSOCKET_URI.value.
46
+ work_dir (str, optional): Рабочая директория для хранения базы данных. По умолчанию ".".
47
+ logger (logging.Logger | None): Пользовательский логгер. Если не передан — используется
48
+ логгер модуля с именем f"{__name__}.MaxClient".
49
+ headers (UserAgentPayload): Заголовки для подключения к WebSocket.
50
+ token (str | None, optional): Токен авторизации. Если не передан, будет выполнен
51
+ процесс логина по номеру телефона.
52
+ host (str, optional): Хост API сервера. По умолчанию Constants.HOST.value.
53
+ port (int, optional): Порт API сервера. По умолчанию Constants.PORT.value.
54
+ registration (bool, optional): Флаг регистрации нового пользователя. По умолчанию False.
55
+ first_name (str, optional): Имя пользователя для регистрации. Требуется, если registration=True.
56
+ last_name (str | None, optional): Фамилия пользователя для регистрации.
57
+ send_fake_telemetry (bool, optional): Флаг отправки фейковой телеметрии. По умолчанию True.
58
+ proxy (str | Literal[True] | None, optional): Прокси для подключения к WebSocket.
59
+ (См. https://websockets.readthedocs.io/en/stable/topics/proxies.html).
60
+ reconnect (bool, optional): Флаг автоматического переподключения при потере соединения. По умолчанию True.
61
+
62
+
63
+ Raises:
64
+ InvalidPhoneError: Если формат номера телефона неверный.
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ phone: str,
70
+ uri: str = WEBSOCKET_URI,
71
+ headers: UserAgentPayload = UserAgentPayload(),
72
+ token: str | None = None,
73
+ send_fake_telemetry: bool = True,
74
+ host: str = HOST,
75
+ port: int = PORT,
76
+ proxy: str | Literal[True] | None = None,
77
+ work_dir: str = ".",
78
+ registration: bool = False,
79
+ first_name: str = "",
80
+ last_name: str | None = None,
81
+ logger: logging.Logger | None = None,
82
+ reconnect: bool = True,
83
+ reconnect_delay: float = 1.0,
84
+ ) -> None:
85
+ self.logger = logger or logging.getLogger(f"{__name__}")
86
+ self.uri: str = uri
87
+ self.phone: str = phone
88
+ if not self._check_phone():
89
+ raise InvalidPhoneError(self.phone)
90
+ self.host: str = host
91
+ self.port: int = port
92
+ self.registration: bool = registration
93
+ self.first_name: str = first_name
94
+ self.last_name: str | None = last_name
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
+
107
+ self._work_dir: str = work_dir
108
+ self._database_path: Path = Path(work_dir) / "session.db"
109
+ self._database_path.parent.mkdir(parents=True, exist_ok=True)
110
+ self._database_path.touch(exist_ok=True)
111
+ self._database = Database(self._work_dir)
112
+
113
+ self._incoming: asyncio.Queue[dict[str, Any]] | None = None
114
+ self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
115
+ self._recv_task: asyncio.Task[Any] | None = None
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
122
+ self._error_count: int = 0
123
+ self._circuit_breaker: bool = False
124
+ self._last_error_time: float = 0.0
125
+
126
+ self._device_id = self._database.get_device_id()
127
+ self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
128
+
129
+ self._token = self._database.get_auth_token() or token
130
+ self.user_agent = headers
131
+ self._send_fake_telemetry: bool = send_fake_telemetry
132
+ self._session_id: int = int(time.time() * 1000)
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
+
147
+ self._ssl_context = ssl.create_default_context()
148
+ self._ssl_context.set_ciphers("DEFAULT")
149
+ self._ssl_context.check_hostname = True
150
+ self._ssl_context.verify_mode = ssl.CERT_REQUIRED
151
+ self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
152
+ self._ssl_context.load_default_certs()
153
+ self._socket: socket.socket | None = None
154
+ self._ws: websockets.ClientConnection | None = None
155
+
156
+ self._setup_logger()
157
+ self.logger.debug(
158
+ "Initialized MaxClient uri=%s work_dir=%s",
159
+ self.uri,
160
+ self._work_dir,
161
+ )
162
+
163
+ def _setup_logger(self) -> None:
164
+ if not self.logger.handlers:
165
+ if not self.logger.level:
166
+ self.logger.setLevel(logging.INFO)
167
+ handler = logging.StreamHandler()
168
+ formatter = ColoredFormatter(
169
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
170
+ datefmt="%Y-%m-%d %H:%M:%S",
171
+ )
172
+ handler.setFormatter(formatter)
173
+ self.logger.addHandler(handler)
174
+
175
+ async def _wait_forever(self):
176
+ try:
177
+ await self.ws.wait_closed()
178
+ except asyncio.CancelledError:
179
+ self.logger.debug("wait_closed cancelled")
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
+
193
+ async def close(self) -> None:
194
+ try:
195
+ self.logger.info("Closing client")
196
+ if self._recv_task:
197
+ self._recv_task.cancel()
198
+ try:
199
+ await self._recv_task
200
+ except asyncio.CancelledError:
201
+ self.logger.debug("recv_task cancelled")
202
+ if self._outgoing_task:
203
+ self._outgoing_task.cancel()
204
+ try:
205
+ await self._outgoing_task
206
+ except asyncio.CancelledError:
207
+ self.logger.debug("outgoing_task cancelled")
208
+ if self._ws:
209
+ await self._ws.close()
210
+ self.is_connected = False
211
+ self.logger.info("Client closed")
212
+ except Exception:
213
+ self.logger.exception("Error closing client")
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
+
235
+ async def start(self) -> None:
236
+ """
237
+ Запускает клиент, подключается к WebSocket, авторизует
238
+ пользователя (если нужно) и запускает фоновый цикл.
239
+ Теперь включает безопасный reconnect-loop, если self.reconnect=True.
240
+ """
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)
331
+
332
+
333
+ class SocketMaxClient(SocketMixin, MaxClient):
334
+ @override
335
+ async def _wait_forever(self):
336
+ if self._recv_task:
337
+ try:
338
+ await self._recv_task
339
+ except asyncio.CancelledError:
340
+ self.logger.debug("Socket recv_task cancelled")
341
+ except Exception as e:
342
+ self.logger.exception("Socket recv_task failed: %s", e)
@@ -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)