maxapi-python 1.1.14__py3-none-any.whl → 1.1.15__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.14
3
+ Version: 1.1.15
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
@@ -145,7 +145,7 @@ if __name__ == "__main__":
145
145
 
146
146
  ## Документация
147
147
 
148
- [WIP](https://ink-developer.github.io/)
148
+ [WIP](https://ink-developer.github.io/PyMax)
149
149
 
150
150
  ## Лицензия
151
151
 
@@ -1,30 +1,30 @@
1
1
  pymax/__init__.py,sha256=jXY_nQKTdCOqXqJWNkyNIudoxfQO5p8wNmZYbuO1Rgs,980
2
- pymax/core.py,sha256=EpMTja4oUc5BqFzEQiFOdrCruE3XbRg08cR_IettiWI,6567
2
+ pymax/core.py,sha256=Yx8pLJ6s8MfxxaQrw5nbSBaYP5m9yZz51O78bUmK32I,8495
3
3
  pymax/crud.py,sha256=wmJh8MPi3L_HbYp7MJP0eXfDcnjgfkLDa9rHAmXtkow,3219
4
4
  pymax/exceptions.py,sha256=PWEjx7d70410XMvyVVyQUwR88CTgm8MUQ4RyInTut_M,2046
5
- pymax/files.py,sha256=NpS7iGA_8EX65u8Tuo0gIWg0c9RnGSOX0HhV6stCtSo,2775
5
+ pymax/files.py,sha256=oKWylKZUPlChWIo2PUHxoq4dcEbg4hAD6ITZCkiN6Xg,3086
6
6
  pymax/filters.py,sha256=4hehzyQlyxBxLiEhtSH_KQdFHx8gYaqyIOOD72EsSS0,1536
7
7
  pymax/formatting.py,sha256=hhtmakfcQDzQRsAckPunnJOCKY2lFdLeIp8Yw53yY8s,2522
8
- pymax/interfaces.py,sha256=mRJiAiMjinPbAlHdLplyiS-zSGOdnB1Q8wEANyDrvtk,2477
8
+ pymax/interfaces.py,sha256=onU6yxkU7sUjSeZWlHflONzZiWIspmXYS5Byeuvscvc,3288
9
9
  pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
10
10
  pymax/navigation.py,sha256=4ia6RGY2pXMArboNhHkbWlWX7LtcYK1VGVXorPX0Pb4,5747
11
- pymax/payloads.py,sha256=p2a4cIPuxrdvv82JBztn1tZDAtBExGi-6gZlqVhabaI,5892
11
+ pymax/payloads.py,sha256=BwrZKVwsoBzMLWlskARMsk4xQ8bpnizoPbv_P4ez01Q,6190
12
12
  pymax/types.py,sha256=mM2cbdwcevketJ-O1F_jeNe7WvYpooV0F1ojbELDdvE,31141
13
13
  pymax/utils.py,sha256=r6Gm-fol6d4LdcdC12j6kQQNi1ywg7WVe24kwPh919A,1455
14
14
  pymax/mixins/__init__.py,sha256=xvjcq-lFVHCPss_t8xxXya0OJnsh-owlBqtUlrXSCcw,695
15
- pymax/mixins/auth.py,sha256=6b83Bd3rn3qQvhFU1GYAFUAXA6faT1S1p2eGqUeJR5E,3764
15
+ pymax/mixins/auth.py,sha256=KlvVjdK3Hj7LeiCCWpGbQ_oPsww6KXQ_L_SxWD-yW3k,6526
16
16
  pymax/mixins/channel.py,sha256=2oSLjZp0qm4lKAoFKGivhldajTv0F59_ozfukw7OLTI,4478
17
17
  pymax/mixins/group.py,sha256=RAluoZUIh0KCKy37R0efjCMm0kptEjDyjjkDF7c97No,10533
18
18
  pymax/mixins/handler.py,sha256=xlfZ9UX1iFaq7gh4E9hxPtGRwj-O2hiqx-C98IxTvQE,3877
19
- pymax/mixins/message.py,sha256=lHbZI9_dHWZk1wyCW-lpy_S1kkzfisNbfblhVQJZON0,21159
19
+ pymax/mixins/message.py,sha256=-ixtb1TrNrNs3kZUe4fYdPVW3I5WdHiao9w-r_CfO8w,24369
20
20
  pymax/mixins/self.py,sha256=TU1lWct5z9rNSsB4aVFial7UME3sfffTWlRAR2jpan8,1196
21
- pymax/mixins/socket.py,sha256=098W0YQ8A-_iSIgNj6nNtyydZk4eagXyOYcbl3ls0bI,19156
21
+ pymax/mixins/socket.py,sha256=OYhzOOvFgMOW4sR0wGPbObcFeoESd4pOyJAoIWSx6kU,23025
22
22
  pymax/mixins/telemetry.py,sha256=kxsecLhDYeRGX5YviyXo4zJKQyjoyPkmPa_-vDrqMQQ,3625
23
23
  pymax/mixins/user.py,sha256=CgFrcvpCefEB4bbuxLdcxW3W_ZZs5gupq74S45cU6Es,5538
24
- pymax/mixins/websocket.py,sha256=LRiJr1LEUK-MLZ9Jx6ZKIRjfngW_kfheIawRKGUR46c,12357
24
+ pymax/mixins/websocket.py,sha256=c0cxyR2QKmv87JIcr4ZIgz-M7Oa5-FgyE5Ex0i5fOYw,16541
25
25
  pymax/static/constant.py,sha256=Q1NrmaRj17Gdhk3FmUp3HIwrad1TDorq3wFdQlOCzN8,1027
26
- pymax/static/enum.py,sha256=IK2xxZQa1YzXKBwSjw-H-LIt1EOhcqfiA7C9JVLjygQ,4454
27
- maxapi_python-1.1.14.dist-info/METADATA,sha256=S7RmW8BvXL7d_OKDE97bUpHbBN5ykFyXlkX8UkO-RDU,6047
28
- maxapi_python-1.1.14.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
29
- maxapi_python-1.1.14.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
30
- maxapi_python-1.1.14.dist-info/RECORD,,
26
+ pymax/static/enum.py,sha256=9DgJ8BNwLrqxTqQjpdq1vC8YYgFDlL3VFDOfgHsWQqY,4480
27
+ maxapi_python-1.1.15.dist-info/METADATA,sha256=lt3zV8IXZTKOT-v21Bw90vYl1uRkLtbZTmayergCpV8,6052
28
+ maxapi_python-1.1.15.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
29
+ maxapi_python-1.1.15.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
30
+ maxapi_python-1.1.15.dist-info/RECORD,,
pymax/core.py CHANGED
@@ -4,6 +4,7 @@ import socket
4
4
  import ssl
5
5
  import time
6
6
  from pathlib import Path
7
+ from typing import Literal
7
8
 
8
9
  from typing_extensions import override
9
10
 
@@ -36,6 +37,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
36
37
  процесс логина по номеру телефона.
37
38
  host (str, optional): Хост API сервера. По умолчанию Constants.HOST.value.
38
39
  port (int, optional): Порт API сервера. По умолчанию Constants.PORT.value.
40
+ registration (bool, optional): Флаг регистрации нового пользователя. По умолчанию False.
41
+ first_name (str, optional): Имя пользователя для регистрации. Требуется, если registration=True.
42
+ last_name (str | None, optional): Фамилия пользователя для регистрации.
43
+ send_fake_telemetry (bool, optional): Флаг отправки фейковой телеметрии. По умолчанию True.
44
+ proxy (str | Literal[True] | None, optional): Прокси для подключения к WebSocket.
45
+ (См. https://websockets.readthedocs.io/en/stable/topics/proxies.html).
39
46
 
40
47
  Raises:
41
48
  InvalidPhoneError: Если формат номера телефона неверный.
@@ -50,7 +57,11 @@ class MaxClient(ApiMixin, WebSocketMixin):
50
57
  send_fake_telemetry: bool = True,
51
58
  host: str = HOST,
52
59
  port: int = PORT,
60
+ proxy: str | Literal[True] | None = None,
53
61
  work_dir: str = ".",
62
+ registration: bool = False,
63
+ first_name: str = "",
64
+ last_name: str | None = None,
54
65
  logger: logging.Logger | None = None,
55
66
  ) -> None:
56
67
  logger = logger or logging.getLogger(f"{__name__}.MaxClient")
@@ -62,12 +73,22 @@ class MaxClient(ApiMixin, WebSocketMixin):
62
73
  raise InvalidPhoneError(self.phone)
63
74
  self.host: str = host
64
75
  self.port: int = port
76
+ self.registration: bool = registration
77
+ self.first_name: str = first_name
78
+ self.last_name: str | None = last_name
79
+ self.proxy: str | Literal[True] | None = proxy
65
80
  self._work_dir: str = work_dir
66
81
  self._database_path: Path = Path(work_dir) / "session.db"
67
82
  self._database_path.parent.mkdir(parents=True, exist_ok=True)
68
83
  self._database_path.touch(exist_ok=True)
69
84
  self._database = Database(self._work_dir)
85
+ self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
86
+ self._outgoing_task: asyncio.Task[Any] | None = None
87
+ self._error_count: int = 0
88
+ self._circuit_breaker: bool = False
89
+ self._last_error_time: float = 0.0
70
90
  self._device_id = self._database.get_device_id()
91
+ self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
71
92
  self._token = self._database.get_auth_token() or token
72
93
  self.user_agent = headers
73
94
  self._send_fake_telemetry: bool = send_fake_telemetry
@@ -111,6 +132,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
111
132
  await self._recv_task
112
133
  except asyncio.CancelledError:
113
134
  self.logger.debug("recv_task cancelled")
135
+ if self._outgoing_task:
136
+ self._outgoing_task.cancel()
137
+ try:
138
+ await self._outgoing_task
139
+ except asyncio.CancelledError:
140
+ self.logger.debug("outgoing_task cancelled")
114
141
  if self._ws:
115
142
  await self._ws.close()
116
143
  self.is_connected = False
@@ -127,6 +154,11 @@ class MaxClient(ApiMixin, WebSocketMixin):
127
154
  self.logger.info("Client starting")
128
155
  await self._connect(self.user_agent)
129
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
+
130
162
  if self._token and self._database.get_auth_token() is None:
131
163
  self._database.update_auth_token(self._device_id, self._token)
132
164
 
pymax/files.py CHANGED
@@ -9,9 +9,7 @@ from typing_extensions import override
9
9
 
10
10
 
11
11
  class BaseFile(ABC):
12
- def __init__(
13
- self, url: str | None = None, path: str | None = None
14
- ) -> None:
12
+ def __init__(self, url: str | None = None, path: str | None = None) -> None:
15
13
  self.url = url
16
14
  self.path = path
17
15
 
@@ -47,9 +45,7 @@ class Photo(BaseFile):
47
45
  ".bmp",
48
46
  } # FIXME: костыль ✅
49
47
 
50
- def __init__(
51
- self, url: str | None = None, path: str | None = None
52
- ) -> None:
48
+ def __init__(self, url: str | None = None, path: str | None = None) -> None:
53
49
  super().__init__(url, path)
54
50
 
55
51
  def validate_photo(self) -> tuple[str, str] | None:
@@ -71,9 +67,7 @@ class Photo(BaseFile):
71
67
  mime_type = mimetypes.guess_type(self.url)[0]
72
68
 
73
69
  if not mime_type or not mime_type.startswith("image/"):
74
- raise ValueError(
75
- f"URL does not appear to be an image: {self.url}"
76
- )
70
+ raise ValueError(f"URL does not appear to be an image: {self.url}")
77
71
 
78
72
  return (extension[1:], mime_type)
79
73
  return None
@@ -90,6 +84,18 @@ class Video(BaseFile):
90
84
 
91
85
 
92
86
  class File(BaseFile):
87
+ def __init__(self, url: str | None = None, path: str | None = None) -> None:
88
+ self.file_name: str = ""
89
+ if path:
90
+ self.file_name = Path(path).name
91
+ elif url:
92
+ self.file_name = Path(url).name
93
+
94
+ if not self.file_name:
95
+ raise ValueError("Either url or path must be provided.")
96
+
97
+ super().__init__(url, path)
98
+
93
99
  @override
94
100
  async def read(self) -> bytes:
95
101
  return await super().read()
pymax/interfaces.py CHANGED
@@ -5,10 +5,12 @@ from abc import ABC, abstractmethod
5
5
  from collections.abc import Awaitable, Callable
6
6
  from logging import Logger
7
7
  from pathlib import Path
8
- from typing import TYPE_CHECKING, Any
8
+ 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
+
12
14
  from .filters import Filter
13
15
  from .payloads import UserAgentPayload
14
16
  from .static.constant import DEFAULT_TIMEOUT
@@ -37,6 +39,10 @@ class ClientProtocol(ABC):
37
39
  self.me: Me | None = None
38
40
  self.host: str
39
41
  self.port: int
42
+ self.proxy: str | Literal[True] | None
43
+ self.registration: bool
44
+ self.first_name: str
45
+ self.last_name: str | None
40
46
  self._work_dir: str
41
47
  self._database_path: Path
42
48
  self._ws: websockets.ClientConnection | None = None
@@ -44,7 +50,14 @@ class ClientProtocol(ABC):
44
50
  self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
45
51
  self._recv_task: asyncio.Task[Any] | None = None
46
52
  self._incoming: asyncio.Queue[dict[str, Any]] | None = None
53
+ self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
47
54
  self.user_agent = UserAgentPayload()
55
+ self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
56
+ self._outgoing_task: asyncio.Task[Any] | None = None
57
+ self._error_count: int = 0
58
+ self._circuit_breaker: bool = False
59
+ self._last_error_time: float = 0.0
60
+ self.user_agent = DEFAULT_USER_AGENT
48
61
  self._session_id: int
49
62
  self._action_id: int = 0
50
63
  self._current_screen: str = "chats_list_tab"
@@ -57,9 +70,7 @@ class ClientProtocol(ABC):
57
70
  self._on_message_delete_handlers: list[
58
71
  tuple[Callable[[Message], Any], Filter | None]
59
72
  ] = []
60
- self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = (
61
- None
62
- )
73
+ self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
63
74
  self._background_tasks: set[asyncio.Task[Any]] = set()
64
75
  self._ssl_context: ssl.SSLContext
65
76
  self._socket: socket.socket | None = None
@@ -77,3 +88,14 @@ class ClientProtocol(ABC):
77
88
  @abstractmethod
78
89
  async def _get_chat(self, chat_id: int) -> Chat | None:
79
90
  pass
91
+
92
+ @abstractmethod
93
+ async def _queue_message(
94
+ self,
95
+ opcode: int,
96
+ payload: dict[str, Any],
97
+ cmd: int = 0,
98
+ timeout: float = DEFAULT_TIMEOUT,
99
+ max_retries: int = 3,
100
+ ) -> Message | None:
101
+ pass
pymax/mixins/auth.py CHANGED
@@ -1,15 +1,15 @@
1
1
  import asyncio
2
2
  import re
3
+ import sys
3
4
  from typing import Any
4
5
 
5
6
  from pymax.interfaces import ClientProtocol
6
- from pymax.payloads import RequestCodePayload, SendCodePayload
7
+ from pymax.payloads import RegisterPayload, RequestCodePayload, SendCodePayload
7
8
  from pymax.static.constant import PHONE_REGEX
8
9
  from pymax.static.enum import AuthType, Opcode
9
10
 
10
11
 
11
12
  class AuthMixin(ClientProtocol):
12
-
13
13
  def __init__(self, token: str | None = None, *args, **kwargs) -> None:
14
14
  super().__init__(*args, **kwargs)
15
15
  self._token = token
@@ -55,9 +55,7 @@ class AuthMixin(ClientProtocol):
55
55
  auth_token_type=AuthType.CHECK_CODE,
56
56
  ).model_dump(by_alias=True)
57
57
 
58
- data = await self._send_and_wait(
59
- opcode=Opcode.AUTH, payload=payload
60
- )
58
+ data = await self._send_and_wait(opcode=Opcode.AUTH, payload=payload)
61
59
  self.logger.debug(
62
60
  "Send code response opcode=%s seq=%s",
63
61
  data.get("opcode"),
@@ -75,13 +73,15 @@ class AuthMixin(ClientProtocol):
75
73
 
76
74
  async def _login(self) -> None:
77
75
  self.logger.info("Starting login flow")
76
+
78
77
  request_code_payload = await self._request_code(self.phone)
79
78
  temp_token = request_code_payload.get("token")
80
79
  if not temp_token or not isinstance(temp_token, str):
81
80
  self.logger.critical("Failed to request code: token missing")
82
81
  raise ValueError("Failed to request code")
83
82
 
84
- code = await asyncio.to_thread(input, "Введите код: ")
83
+ print("Введите код: ", end="", flush=True)
84
+ code = await asyncio.to_thread(lambda: sys.stdin.readline().strip())
85
85
  if len(code) != 6 or not code.isdigit():
86
86
  self.logger.error("Invalid code format entered")
87
87
  raise ValueError("Invalid code format")
@@ -97,3 +97,66 @@ class AuthMixin(ClientProtocol):
97
97
  self._token = token
98
98
  self._database.update_auth_token(self._device_id, self._token)
99
99
  self.logger.info("Login successful, token saved to database")
100
+
101
+ async def _submit_reg_info(
102
+ self, first_name: str, last_name: str | None, token: str
103
+ ) -> dict[str, Any]:
104
+ try:
105
+ self.logger.info("Submitting registration info")
106
+
107
+ payload = RegisterPayload(
108
+ first_name=first_name,
109
+ last_name=last_name,
110
+ token=token,
111
+ ).model_dump(by_alias=True)
112
+
113
+ data = await self._send_and_wait(
114
+ opcode=Opcode.AUTH_CONFIRM, payload=payload
115
+ )
116
+ self.logger.debug(
117
+ "Registration info response opcode=%s seq=%s",
118
+ data.get("opcode"),
119
+ data.get("seq"),
120
+ )
121
+ payload_data = data.get("payload")
122
+ if isinstance(payload_data, dict):
123
+ return payload_data
124
+ else:
125
+ self.logger.error("Invalid payload data received")
126
+ raise ValueError("Invalid payload data received")
127
+ except Exception:
128
+ self.logger.error("Submit registration info failed", exc_info=True)
129
+ raise RuntimeError("Submit registration info failed")
130
+
131
+ async def _register(self, first_name: str, last_name: str | None = None) -> None:
132
+ self.logger.info("Starting registration flow")
133
+
134
+ request_code_payload = await self._request_code(self.phone)
135
+ temp_token = request_code_payload.get("token")
136
+
137
+ if not temp_token or not isinstance(temp_token, str):
138
+ self.logger.critical("Failed to request code: token missing")
139
+ raise ValueError("Failed to request code")
140
+
141
+ print("Введите код: ", end="", flush=True)
142
+ code = await asyncio.to_thread(lambda: sys.stdin.readline().strip())
143
+ if len(code) != 6 or not code.isdigit():
144
+ self.logger.error("Invalid code format entered")
145
+ raise ValueError("Invalid code format")
146
+
147
+ registration_response = await self._send_code(code, temp_token)
148
+ token: str | None = (
149
+ registration_response.get("tokenAttrs", {}).get("REGISTER", {}).get("token")
150
+ )
151
+ if not token:
152
+ self.logger.critical("Failed to register, token not received")
153
+ raise ValueError("Failed to register, token not received")
154
+
155
+ data = await self._submit_reg_info(first_name, last_name, token)
156
+ self._token = data.get("token")
157
+ if not self._token:
158
+ self.logger.critical("Failed to register, token not received")
159
+ raise ValueError("Failed to register, token not received")
160
+
161
+ self._database.update_auth_token(self._device_id, self._token)
162
+ self.logger.info("Registration successful, token saved to database")
pymax/mixins/message.py CHANGED
@@ -1,13 +1,15 @@
1
+ import asyncio
1
2
  import time
2
3
 
3
4
  import aiohttp
4
5
  from aiohttp import ClientSession
5
6
 
6
- from pymax.files import Photo
7
+ from pymax.files import File, Photo
7
8
  from pymax.formatting import Formatting
8
9
  from pymax.interfaces import ClientProtocol
9
10
  from pymax.payloads import (
10
11
  AddReactionPayload,
12
+ AttachFilePayload,
11
13
  AttachPhotoPayload,
12
14
  DeleteMessagePayload,
13
15
  EditMessagePayload,
@@ -22,8 +24,9 @@ from pymax.payloads import (
22
24
  ReplyLink,
23
25
  SendMessagePayload,
24
26
  SendMessagePayloadMessage,
25
- UploadPhotoPayload,
27
+ UploadPayload,
26
28
  )
29
+ from pymax.static.constant import DEFAULT_TIMEOUT
27
30
  from pymax.static.enum import AttachType, Opcode
28
31
  from pymax.types import (
29
32
  Attach,
@@ -35,10 +38,70 @@ from pymax.types import (
35
38
 
36
39
 
37
40
  class MessageMixin(ClientProtocol):
41
+ async def _upload_file(self, file: File) -> None | Attach:
42
+ try:
43
+ self.logger.info("Uploading file")
44
+ payload = UploadPayload().model_dump(by_alias=True)
45
+ data = await self._send_and_wait(
46
+ opcode=Opcode.FILE_UPLOAD,
47
+ payload=payload,
48
+ )
49
+ if error := data.get("payload", {}).get("error"):
50
+ self.logger.error("Upload file error: %s", error)
51
+ return None
52
+
53
+ url = data.get("payload", {}).get("info", [None])[0].get("url", None)
54
+ file_id = data.get("payload", {}).get("info", [None])[0].get("fileId", None)
55
+ if not url or not file_id:
56
+ self.logger.error("No upload URL or file ID received")
57
+ return None
58
+
59
+ file_bytes = await file.read()
60
+
61
+ headers = {
62
+ "Content-Disposition": f"attachment; filename={file.file_name}",
63
+ "Content-Range": f"0-{len(file_bytes) - 1}/{len(file_bytes)}",
64
+ }
65
+
66
+ loop = asyncio.get_running_loop()
67
+ fut: asyncio.Future[dict] = loop.create_future()
68
+ try:
69
+ self._file_upload_waiters[int(file_id)] = fut
70
+ except Exception:
71
+ self.logger.exception("Failed to register file upload waiter")
72
+
73
+ async with (
74
+ ClientSession() as session,
75
+ session.post(
76
+ url=url,
77
+ headers=headers,
78
+ data=file_bytes,
79
+ ) as response,
80
+ ):
81
+ if response.status != 200:
82
+ self.logger.error(f"Upload failed with status {response.status}")
83
+ # cleanup waiter
84
+ self._file_upload_waiters.pop(int(file_id), None)
85
+ return None
86
+
87
+ try:
88
+ await asyncio.wait_for(fut, timeout=DEFAULT_TIMEOUT)
89
+ return Attach(_type=AttachType.FILE, file_id=file_id)
90
+ except asyncio.TimeoutError:
91
+ self.logger.error(
92
+ "Timed out waiting for file processing notification for fileId=%s",
93
+ file_id,
94
+ )
95
+ self._file_upload_waiters.pop(int(file_id), None)
96
+ return None
97
+ except Exception as e:
98
+ self.logger.exception("Upload file failed: %s", str(e))
99
+ return None
100
+
38
101
  async def _upload_photo(self, photo: Photo) -> None | Attach:
39
102
  try:
40
103
  self.logger.info("Uploading photo")
41
- payload = UploadPhotoPayload().model_dump(by_alias=True)
104
+ payload = UploadPayload().model_dump(by_alias=True)
42
105
 
43
106
  data = await self._send_and_wait(
44
107
  opcode=Opcode.PHOTO_UPLOAD,
@@ -74,9 +137,7 @@ class MessageMixin(ClientProtocol):
74
137
  ) as response,
75
138
  ):
76
139
  if response.status != 200:
77
- self.logger.error(
78
- f"Upload failed with status {response.status}"
79
- )
140
+ self.logger.error(f"Upload failed with status {response.status}")
80
141
  return None
81
142
 
82
143
  result = await response.json()
@@ -99,52 +160,61 @@ class MessageMixin(ClientProtocol):
99
160
  self.logger.exception("Upload photo failed: %s", str(e))
100
161
  return None
101
162
 
163
+ async def _upload_attachment(self, attach: Photo | File) -> dict | None:
164
+ if isinstance(attach, Photo):
165
+ uploaded = await self._upload_photo(attach)
166
+ if uploaded and uploaded.photo_token:
167
+ return AttachPhotoPayload(photo_token=uploaded.photo_token).model_dump(
168
+ by_alias=True
169
+ )
170
+ elif isinstance(attach, File):
171
+ uploaded = await self._upload_file(attach)
172
+ if uploaded and uploaded.file_id:
173
+ return AttachFilePayload(file_id=uploaded.file_id).model_dump(
174
+ by_alias=True
175
+ )
176
+ self.logger.error(f"Attachment upload failed for {attach}")
177
+ return None
178
+
102
179
  async def send_message(
103
180
  self,
104
181
  text: str,
105
182
  chat_id: int,
106
183
  notify: bool,
107
- photo: Photo | None = None,
108
- photos: list[Photo] | None = None,
184
+ attachment: Photo | File | None = None,
185
+ attachments: list[Photo | File] | None = None,
109
186
  reply_to: int | None = None,
187
+ use_queue: bool = False,
110
188
  ) -> Message | None:
111
189
  """
112
190
  Отправляет сообщение в чат.
113
191
  """
114
192
  try:
115
- self.logger.info(
116
- "Sending message to chat_id=%s notify=%s", chat_id, notify
117
- )
118
- if photos and photo:
119
- self.logger.warning(
120
- "Both photo and photos provided; using photos"
121
- )
122
- photo = None
193
+ self.logger.info("Sending message to chat_id=%s notify=%s", chat_id, notify)
194
+ if attachments and attachment:
195
+ self.logger.warning("Both photo and photos provided; using photos")
196
+ attachment = None
123
197
  attaches = []
124
- if photo:
125
- self.logger.info("Uploading photo for message")
126
- attach = await self._upload_photo(photo)
127
- if not attach or not attach.photo_token:
128
- self.logger.error("Photo upload failed, message not sent")
198
+ if attachment:
199
+ self.logger.info("Uploading attachment for message")
200
+ result = await self._upload_attachment(attachment)
201
+ if not result:
202
+ self.logger.error("Attachment upload failed, message not sent")
129
203
  return None
130
- attaches = [
131
- AttachPhotoPayload(
132
- photo_token=attach.photo_token
133
- ).model_dump(by_alias=True)
134
- ]
135
- elif photos:
136
- self.logger.info("Uploading multiple photos for message")
137
- for p in photos:
138
- attach = await self._upload_photo(p)
139
- if attach and attach.photo_token:
140
- attaches.append(
141
- AttachPhotoPayload(
142
- photo_token=attach.photo_token
143
- ).model_dump(by_alias=True)
144
- )
204
+ attaches.append(result)
205
+
206
+ elif attachments:
207
+ self.logger.info("Uploading multiple attachments for message")
208
+ for p in attachments:
209
+ result = await self._upload_attachment(p)
210
+ if result:
211
+ attaches.append(result)
212
+ else:
213
+ self.logger.error("One of attachments upload failed")
214
+
145
215
  if not attaches:
146
216
  self.logger.error(
147
- "All photo uploads failed, message not sent"
217
+ "All attachments uploads failed, message not sent"
148
218
  )
149
219
  return None
150
220
 
@@ -165,28 +235,27 @@ class MessageMixin(ClientProtocol):
165
235
  cid=int(time.time() * 1000),
166
236
  elements=elements,
167
237
  attaches=attaches,
168
- link=(
169
- ReplyLink(message_id=str(reply_to))
170
- if reply_to
171
- else None
172
- ),
238
+ link=(ReplyLink(message_id=str(reply_to)) if reply_to else None),
173
239
  ),
174
240
  notify=notify,
175
241
  ).model_dump(by_alias=True)
176
242
 
177
- data = await self._send_and_wait(
178
- opcode=Opcode.MSG_SEND, payload=payload
179
- )
180
- if error := data.get("payload", {}).get("error"):
181
- self.logger.error("Send message error: %s", error)
243
+ if use_queue:
244
+ await self._queue_message(opcode=Opcode.MSG_SEND, payload=payload)
245
+ self.logger.debug("Message queued for sending")
182
246
  return None
183
- msg = (
184
- Message.from_dict(data["payload"])
185
- if data.get("payload")
186
- else None
187
- )
188
- self.logger.debug("send_message result: %r", msg)
189
- return msg
247
+ else:
248
+ data = await self._send_and_wait(
249
+ opcode=Opcode.MSG_SEND, payload=payload
250
+ )
251
+ if error := data.get("payload", {}).get("error"):
252
+ self.logger.error("Send message error: %s", error)
253
+ return None
254
+ msg = (
255
+ Message.from_dict(data["payload"]) if data.get("payload") else None
256
+ )
257
+ self.logger.debug("send_message result: %r", msg)
258
+ return msg
190
259
  except Exception:
191
260
  self.logger.exception("Send message failed")
192
261
  return None
@@ -196,47 +265,39 @@ class MessageMixin(ClientProtocol):
196
265
  chat_id: int,
197
266
  message_id: int,
198
267
  text: str,
199
- photo: Photo | None = None,
200
- photos: list[Photo] | None = None,
268
+ attachment: Photo | None = None,
269
+ attachments: list[Photo] | None = None,
270
+ use_queue: bool = False,
201
271
  ) -> Message | None:
202
- """
203
- Редактирует сообщение.
204
- """
205
272
  try:
206
273
  self.logger.info(
207
274
  "Editing message chat_id=%s message_id=%s", chat_id, message_id
208
275
  )
209
276
 
210
- if photos and photo:
211
- self.logger.warning(
212
- "Both photo and photos provided; using photos"
213
- )
214
- photo = None
277
+ if attachments and attachment:
278
+ self.logger.warning("Both photo and photos provided; using photos")
279
+ attachment = None
215
280
  attaches = []
216
- if photo:
217
- self.logger.info("Uploading photo for message")
218
- attach = await self._upload_photo(photo)
219
- if not attach or not attach.photo_token:
220
- self.logger.error("Photo upload failed, message not sent")
281
+ if attachment:
282
+ self.logger.info("Uploading attachment for message")
283
+ result = await self._upload_attachment(attachment)
284
+ if not result:
285
+ self.logger.error("Attachment upload failed, message not sent")
221
286
  return None
222
- attaches = [
223
- AttachPhotoPayload(
224
- photo_token=attach.photo_token
225
- ).model_dump(by_alias=True)
226
- ]
227
- elif photos:
228
- self.logger.info("Uploading multiple photos for message")
229
- for p in photos:
230
- attach = await self._upload_photo(p)
231
- if attach and attach.photo_token:
232
- attaches.append(
233
- AttachPhotoPayload(
234
- photo_token=attach.photo_token
235
- ).model_dump(by_alias=True)
236
- )
287
+ attaches.append(result)
288
+
289
+ elif attachments:
290
+ self.logger.info("Uploading multiple attachments for message")
291
+ for p in attachment:
292
+ result = await self._upload_attachment(p)
293
+ if result:
294
+ attaches.append(result)
295
+ else:
296
+ self.logger.error("One of attachments upload failed")
297
+
237
298
  if not attaches:
238
299
  self.logger.error(
239
- "All photo uploads failed, message not sent"
300
+ "All attachments uploads failed, message not sent"
240
301
  )
241
302
  return None
242
303
 
@@ -257,24 +318,32 @@ class MessageMixin(ClientProtocol):
257
318
  elements=elements,
258
319
  attaches=attaches,
259
320
  ).model_dump(by_alias=True)
260
- data = await self._send_and_wait(
261
- opcode=Opcode.MSG_EDIT, payload=payload
262
- )
263
- if error := data.get("payload", {}).get("error"):
264
- self.logger.error("Edit message error: %s", error)
265
- msg = (
266
- Message.from_dict(data["payload"])
267
- if data.get("payload")
268
- else None
269
- )
270
- self.logger.debug("edit_message result: %r", msg)
271
- return msg
321
+
322
+ if use_queue:
323
+ await self._queue_message(opcode=Opcode.MSG_EDIT, payload=payload)
324
+ self.logger.debug("Edit message queued for sending")
325
+ return None
326
+ else:
327
+ data = await self._send_and_wait(
328
+ opcode=Opcode.MSG_EDIT, payload=payload
329
+ )
330
+ if error := data.get("payload", {}).get("error"):
331
+ self.logger.error("Edit message error: %s", error)
332
+ msg = (
333
+ Message.from_dict(data["payload"]) if data.get("payload") else None
334
+ )
335
+ self.logger.debug("edit_message result: %r", msg)
336
+ return msg
272
337
  except Exception:
273
338
  self.logger.exception("Edit message failed")
274
339
  return None
275
340
 
276
341
  async def delete_message(
277
- self, chat_id: int, message_ids: list[int], for_me: bool
342
+ self,
343
+ chat_id: int,
344
+ message_ids: list[int],
345
+ for_me: bool,
346
+ use_queue: bool = False,
278
347
  ) -> bool:
279
348
  """
280
349
  Удаляет сообщения.
@@ -291,14 +360,19 @@ class MessageMixin(ClientProtocol):
291
360
  chat_id=chat_id, message_ids=message_ids, for_me=for_me
292
361
  ).model_dump(by_alias=True)
293
362
 
294
- data = await self._send_and_wait(
295
- opcode=Opcode.MSG_DELETE, payload=payload
296
- )
297
- if error := data.get("payload", {}).get("error"):
298
- self.logger.error("Delete message error: %s", error)
299
- return False
300
- self.logger.debug("delete_message success")
301
- return True
363
+ if use_queue:
364
+ await self._queue_message(opcode=Opcode.MSG_DELETE, payload=payload)
365
+ self.logger.debug("Delete message queued for sending")
366
+ return True
367
+ else:
368
+ data = await self._send_and_wait(
369
+ opcode=Opcode.MSG_DELETE, payload=payload
370
+ )
371
+ if error := data.get("payload", {}).get("error"):
372
+ self.logger.error("Delete message error: %s", error)
373
+ return False
374
+ self.logger.debug("delete_message success")
375
+ return True
302
376
  except Exception:
303
377
  self.logger.exception("Delete message failed")
304
378
  return False
@@ -324,9 +398,7 @@ class MessageMixin(ClientProtocol):
324
398
  pin_message_id=message_id,
325
399
  ).model_dump(by_alias=True)
326
400
 
327
- data = await self._send_and_wait(
328
- opcode=Opcode.CHAT_UPDATE, payload=payload
329
- )
401
+ data = await self._send_and_wait(opcode=Opcode.CHAT_UPDATE, payload=payload)
330
402
  if error := data.get("payload", {}).get("error"):
331
403
  self.logger.error("Pin message error: %s", error)
332
404
  return False
@@ -376,8 +448,7 @@ class MessageMixin(ClientProtocol):
376
448
  return None
377
449
 
378
450
  messages = [
379
- Message.from_dict(msg)
380
- for msg in data["payload"].get("messages", [])
451
+ Message.from_dict(msg) for msg in data["payload"].get("messages", [])
381
452
  ]
382
453
  self.logger.debug("History fetched: %d messages", len(messages))
383
454
  return messages
@@ -405,9 +476,7 @@ class MessageMixin(ClientProtocol):
405
476
  url (str): Ссылка на видео
406
477
  """
407
478
  try:
408
- self.logger.info(
409
- "Getting video_id=%s message_id=%s", video_id, message_id
410
- )
479
+ self.logger.info("Getting video_id=%s message_id=%s", video_id, message_id)
411
480
 
412
481
  if self.is_connected and self._socket is not None:
413
482
  payload = GetVideoPayload(
@@ -420,18 +489,14 @@ class MessageMixin(ClientProtocol):
420
489
  video_id=video_id,
421
490
  ).model_dump(by_alias=True)
422
491
 
423
- data = await self._send_and_wait(
424
- opcode=Opcode.VIDEO_PLAY, payload=payload
425
- )
492
+ data = await self._send_and_wait(opcode=Opcode.VIDEO_PLAY, payload=payload)
426
493
 
427
494
  if error := data.get("payload", {}).get("error"):
428
495
  self.logger.error("Get video error: %s", error)
429
496
  return None
430
497
 
431
498
  video = (
432
- VideoRequest.from_dict(data["payload"])
433
- if data.get("payload")
434
- else None
499
+ VideoRequest.from_dict(data["payload"]) if data.get("payload") else None
435
500
  )
436
501
  self.logger.debug("result: %r", video)
437
502
  return video
@@ -458,9 +523,7 @@ class MessageMixin(ClientProtocol):
458
523
  url (str): Ссылка на скачивание файла
459
524
  """
460
525
  try:
461
- self.logger.info(
462
- "Getting file_id=%s message_id=%s", file_id, message_id
463
- )
526
+ self.logger.info("Getting file_id=%s message_id=%s", file_id, message_id)
464
527
  if self.is_connected and self._socket is not None:
465
528
  payload = GetFilePayload(
466
529
  chat_id=chat_id, message_id=message_id, file_id=file_id
@@ -480,9 +543,7 @@ class MessageMixin(ClientProtocol):
480
543
  return None
481
544
 
482
545
  file = (
483
- FileRequest.from_dict(data["payload"])
484
- if data.get("payload")
485
- else None
546
+ FileRequest.from_dict(data["payload"]) if data.get("payload") else None
486
547
  )
487
548
  self.logger.debug(" result: %r", file)
488
549
  return file
pymax/mixins/socket.py CHANGED
@@ -2,6 +2,7 @@ import asyncio
2
2
  import socket
3
3
  import ssl
4
4
  import sys
5
+ import time
5
6
  from collections.abc import Callable
6
7
  from typing import Any
7
8
 
@@ -116,8 +117,10 @@ Socket connections may be unstable, SSL issues are possible.
116
117
  self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
117
118
  self.is_connected = True
118
119
  self._incoming = asyncio.Queue()
120
+ self._outgoing = asyncio.Queue()
119
121
  self._pending = {}
120
122
  self._recv_task = asyncio.create_task(self._recv_loop())
123
+ self._outgoing_task = asyncio.create_task(self._outgoing_loop())
121
124
  self.logger.info("Socket connected, starting handshake")
122
125
  return await self._handshake(user_agent)
123
126
  except Exception as e:
@@ -431,6 +434,96 @@ Socket connections may be unstable, SSL issues are possible.
431
434
  finally:
432
435
  self._pending.pop(msg["seq"], None)
433
436
 
437
+ async def _outgoing_loop(self) -> None:
438
+ while self.is_connected:
439
+ try:
440
+ if self._outgoing is None:
441
+ await asyncio.sleep(0.1)
442
+ continue
443
+
444
+ if self._circuit_breaker:
445
+ if time.time() - self._last_error_time > 60:
446
+ self._circuit_breaker = False
447
+ self._error_count = 0
448
+ self.logger.info("Circuit breaker reset (socket)")
449
+ else:
450
+ await asyncio.sleep(5)
451
+ continue
452
+
453
+ message = await self._outgoing.get() # TODO: persistent msg q mb?
454
+ if not message:
455
+ continue
456
+
457
+ retry_count = message.get("retry_count", 0)
458
+ max_retries = message.get("max_retries", 3)
459
+
460
+ try:
461
+ await self._send_and_wait(
462
+ opcode=message["opcode"],
463
+ payload=message["payload"],
464
+ cmd=message.get("cmd", 0),
465
+ timeout=message.get("timeout", 10.0)
466
+ )
467
+ self.logger.debug("Message sent successfully from queue (socket)")
468
+ self._error_count = max(0, self._error_count - 1)
469
+ except Exception as e:
470
+ self._error_count += 1
471
+ self._last_error_time = time.time()
472
+
473
+ if self._error_count > 10: # TODO: export to constant
474
+ self._circuit_breaker = True
475
+ self.logger.warning("Circuit breaker activated due to %d consecutive errors (socket)", self._error_count)
476
+ await self._outgoing.put(message)
477
+ continue
478
+
479
+ retry_delay = self._get_retry_delay(e, retry_count)
480
+ self.logger.warning("Failed to send message from queue (socket): %s (delay: %ds)", e, retry_delay)
481
+
482
+ if retry_count < max_retries:
483
+ message["retry_count"] = retry_count + 1
484
+ await asyncio.sleep(retry_delay)
485
+ await self._outgoing.put(message)
486
+ else:
487
+ self.logger.error("Message failed after %d retries, dropping (socket)", max_retries)
488
+
489
+ except Exception:
490
+ self.logger.exception("Error in outgoing loop (socket)")
491
+ await asyncio.sleep(1)
492
+
493
+ def _get_retry_delay(self, error: Exception, retry_count: int) -> float: # TODO: tune delays later
494
+ if isinstance(error, (ConnectionError, OSError, ssl.SSLError)):
495
+ return 1.0
496
+ elif isinstance(error, TimeoutError):
497
+ return 5.0
498
+ elif isinstance(error, SocketNotConnectedError):
499
+ return 2.0
500
+ else:
501
+ return 2 ** retry_count
502
+
503
+ async def _queue_message(
504
+ self,
505
+ opcode: int,
506
+ payload: dict[str, Any],
507
+ cmd: int = 0,
508
+ timeout: float = 10.0,
509
+ max_retries: int = 3,
510
+ ) -> None:
511
+ if self._outgoing is None:
512
+ self.logger.warning("Outgoing queue not initialized (socket)")
513
+ return
514
+
515
+ message = {
516
+ "opcode": opcode,
517
+ "payload": payload,
518
+ "cmd": cmd,
519
+ "timeout": timeout,
520
+ "retry_count": 0,
521
+ "max_retries": max_retries,
522
+ }
523
+
524
+ await self._outgoing.put(message)
525
+ self.logger.debug("Message queued for sending (socket)")
526
+
434
527
  async def _sync(self) -> None:
435
528
  try:
436
529
  self.logger.info("Starting initial sync (socket)")
pymax/mixins/websocket.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import json
3
+ import time
3
4
  from collections.abc import Callable
4
5
  from typing import Any
5
6
 
@@ -21,7 +22,6 @@ from pymax.types import Channel, Chat, Dialog, Me, Message
21
22
 
22
23
 
23
24
  class WebSocketMixin(ClientProtocol):
24
-
25
25
  def __init__(self, token: str | None = None, *args, **kwargs) -> None:
26
26
  super().__init__(*args, **kwargs)
27
27
  self._token = token
@@ -29,9 +29,7 @@ class WebSocketMixin(ClientProtocol):
29
29
  @property
30
30
  def ws(self) -> websockets.ClientConnection:
31
31
  if self._ws is None or not self.is_connected:
32
- self.logger.critical(
33
- "WebSocket not connected when access attempted"
34
- )
32
+ self.logger.critical("WebSocket not connected when access attempted")
35
33
  raise WebSocketNotConnectedError
36
34
  return self._ws
37
35
 
@@ -71,12 +69,15 @@ class WebSocketMixin(ClientProtocol):
71
69
  self._ws = await websockets.connect(
72
70
  self.uri,
73
71
  origin=WEBSOCKET_ORIGIN,
74
- user_agent_header=user_agent.headerUserAgent,
72
+ user_agent_header=user_agent.header_user_agent,
73
+ proxy=self.proxy,
75
74
  )
76
75
  self.is_connected = True
77
76
  self._incoming = asyncio.Queue()
77
+ self._outgoing = asyncio.Queue()
78
78
  self._pending = {}
79
79
  self._recv_task = asyncio.create_task(self._recv_loop())
80
+ self._outgoing_task = asyncio.create_task(self._outgoing_loop())
80
81
  self.logger.info("WebSocket connected, starting handshake")
81
82
  return await self._handshake(user_agent)
82
83
  except Exception as e:
@@ -141,9 +142,7 @@ class WebSocketMixin(ClientProtocol):
141
142
 
142
143
  if fut and not fut.done():
143
144
  fut.set_result(data)
144
- self.logger.debug(
145
- "Matched response for pending seq=%s", seq
146
- )
145
+ self.logger.debug("Matched response for pending seq=%s", seq)
147
146
  else:
148
147
  if self._incoming is not None:
149
148
  try:
@@ -154,6 +153,20 @@ class WebSocketMixin(ClientProtocol):
154
153
  data.get("seq"),
155
154
  )
156
155
 
156
+ try: # TODO: переделать, временное решение
157
+ if data.get("opcode") == Opcode.NOTIF_ATTACH:
158
+ file_id = data.get("payload", {}).get("fileId", None)
159
+ if isinstance(file_id, int):
160
+ fut = self._file_upload_waiters.pop(file_id, None)
161
+ if fut and not fut.done():
162
+ fut.set_result(data)
163
+ self.logger.debug(
164
+ "Fulfilled file upload waiter for fileId=%s",
165
+ file_id,
166
+ )
167
+ except Exception:
168
+ self.logger.exception("Error handling file upload notification")
169
+
157
170
  if (
158
171
  data.get("opcode") == Opcode.NOTIF_MESSAGE.value
159
172
  and self._on_message_handlers
@@ -168,23 +181,17 @@ class WebSocketMixin(ClientProtocol):
168
181
  for (
169
182
  edit_handler,
170
183
  edit_filter,
171
- ) in (
172
- self._on_message_edit_handlers
173
- ):
184
+ ) in self._on_message_edit_handlers:
174
185
  await self._process_message_handler(
175
186
  edit_handler,
176
187
  edit_filter,
177
188
  msg,
178
189
  )
179
- elif (
180
- msg.status == MessageStatus.REMOVED
181
- ):
190
+ elif msg.status == MessageStatus.REMOVED:
182
191
  for (
183
192
  remove_handler,
184
193
  remove_filter,
185
- ) in (
186
- self._on_message_delete_handlers
187
- ):
194
+ ) in self._on_message_delete_handlers:
188
195
  await self._process_message_handler(
189
196
  remove_handler,
190
197
  remove_filter,
@@ -194,19 +201,13 @@ class WebSocketMixin(ClientProtocol):
194
201
  handler, filter, msg
195
202
  )
196
203
  except Exception:
197
- self.logger.exception(
198
- "Error in on_message_handler"
199
- )
204
+ self.logger.exception("Error in on_message_handler")
200
205
 
201
206
  except websockets.exceptions.ConnectionClosed:
202
- self.logger.info(
203
- "WebSocket connection closed; exiting recv loop"
204
- )
207
+ self.logger.info("WebSocket connection closed; exiting recv loop")
205
208
  break
206
209
  except Exception:
207
- self.logger.exception(
208
- "Error in recv_loop; backing off briefly"
209
- )
210
+ self.logger.exception("Error in recv_loop; backing off briefly")
210
211
  await asyncio.sleep(RECV_LOOP_BACKOFF_DELAY)
211
212
 
212
213
  def _log_task_exception(self, fut: asyncio.Future[Any]) -> None:
@@ -218,6 +219,30 @@ class WebSocketMixin(ClientProtocol):
218
219
  self.logger.exception("Error retrieving task exception: %s", e)
219
220
  pass
220
221
 
222
+ async def _queue_message(
223
+ self,
224
+ opcode: int,
225
+ payload: dict[str, Any],
226
+ cmd: int = 0,
227
+ timeout: float = DEFAULT_TIMEOUT,
228
+ max_retries: int = 3,
229
+ ) -> None:
230
+ if self._outgoing is None:
231
+ self.logger.warning("Outgoing queue not initialized")
232
+ return
233
+
234
+ message = {
235
+ "opcode": opcode,
236
+ "payload": payload,
237
+ "cmd": cmd,
238
+ "timeout": timeout,
239
+ "retry_count": 0,
240
+ "max_retries": max_retries,
241
+ }
242
+
243
+ await self._outgoing.put(message)
244
+ self.logger.debug("Message queued for sending")
245
+
221
246
  @override
222
247
  async def _send_and_wait(
223
248
  self,
@@ -255,6 +280,81 @@ class WebSocketMixin(ClientProtocol):
255
280
  finally:
256
281
  self._pending.pop(msg["seq"], None)
257
282
 
283
+ async def _outgoing_loop(self) -> None:
284
+ while self.is_connected:
285
+ try:
286
+ if self._outgoing is None:
287
+ await asyncio.sleep(0.1)
288
+ continue
289
+
290
+ if self._circuit_breaker:
291
+ if time.time() - self._last_error_time > 60:
292
+ self._circuit_breaker = False
293
+ self._error_count = 0
294
+ self.logger.info("Circuit breaker reset")
295
+ else:
296
+ await asyncio.sleep(5)
297
+ continue
298
+
299
+ message = await self._outgoing.get() # TODO: persistent msg q mb?
300
+ if not message:
301
+ continue
302
+
303
+ retry_count = message.get("retry_count", 0)
304
+ max_retries = message.get("max_retries", 3)
305
+
306
+ try:
307
+ await self._send_and_wait(
308
+ opcode=message["opcode"],
309
+ payload=message["payload"],
310
+ cmd=message.get("cmd", 0),
311
+ timeout=message.get("timeout", DEFAULT_TIMEOUT),
312
+ )
313
+ self.logger.debug("Message sent successfully from queue")
314
+ self._error_count = max(0, self._error_count - 1)
315
+ except Exception as e:
316
+ self._error_count += 1
317
+ self._last_error_time = time.time()
318
+
319
+ if self._error_count > 10:
320
+ self._circuit_breaker = True
321
+ self.logger.warning(
322
+ "Circuit breaker activated due to %d consecutive errors",
323
+ self._error_count,
324
+ )
325
+ await self._outgoing.put(message)
326
+ continue
327
+
328
+ retry_delay = self._get_retry_delay(e, retry_count)
329
+ self.logger.warning(
330
+ "Failed to send message from queue: %s (delay: %ds)",
331
+ e,
332
+ retry_delay,
333
+ )
334
+
335
+ if retry_count < max_retries:
336
+ message["retry_count"] = retry_count + 1
337
+ await asyncio.sleep(retry_delay)
338
+ await self._outgoing.put(message)
339
+ else:
340
+ self.logger.error(
341
+ "Message failed after %d retries, dropping", max_retries
342
+ )
343
+
344
+ except Exception:
345
+ self.logger.exception("Error in outgoing loop")
346
+ await asyncio.sleep(1)
347
+
348
+ def _get_retry_delay(self, error: Exception, retry_count: int) -> float:
349
+ if isinstance(error, (ConnectionError, OSError)):
350
+ return 1.0
351
+ elif isinstance(error, TimeoutError):
352
+ return 5.0
353
+ elif isinstance(error, WebSocketNotConnectedError):
354
+ return 2.0
355
+ else:
356
+ return 2**retry_count
357
+
258
358
  async def _sync(self) -> None:
259
359
  self.logger.info("Starting initial sync")
260
360
 
@@ -269,9 +369,7 @@ class WebSocketMixin(ClientProtocol):
269
369
  ).model_dump(by_alias=True)
270
370
 
271
371
  try:
272
- data = await self._send_and_wait(
273
- opcode=Opcode.LOGIN, payload=payload
274
- )
372
+ data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload)
275
373
  raw_payload = data.get("payload", {})
276
374
 
277
375
  if error := raw_payload.get("error"):
pymax/payloads.py CHANGED
@@ -64,7 +64,7 @@ class ReplyLink(CamelModel):
64
64
  message_id: str
65
65
 
66
66
 
67
- class UploadPhotoPayload(CamelModel):
67
+ class UploadPayload(CamelModel):
68
68
  count: int = 1
69
69
 
70
70
 
@@ -73,6 +73,11 @@ class AttachPhotoPayload(CamelModel):
73
73
  photo_token: str
74
74
 
75
75
 
76
+ class AttachFilePayload(CamelModel):
77
+ type: AttachType = Field(default=AttachType.FILE, alias="_type")
78
+ file_id: int
79
+
80
+
76
81
  class MessageElement(CamelModel):
77
82
  type: str
78
83
  from_: int = Field(..., alias="from")
@@ -83,7 +88,7 @@ class SendMessagePayloadMessage(CamelModel):
83
88
  text: str
84
89
  cid: int
85
90
  elements: list[MessageElement]
86
- attaches: list[AttachPhotoPayload]
91
+ attaches: list[AttachPhotoPayload | AttachFilePayload]
87
92
  link: ReplyLink | None = None
88
93
 
89
94
 
@@ -263,14 +268,14 @@ class RemoveReactionPayload(CamelModel):
263
268
  message_id: str
264
269
 
265
270
 
266
- class UserAgentPayload(BaseModel):
267
- deviceType: str = Field(default=DEFAULT_DEVICE_TYPE)
271
+ class UserAgentPayload(CamelModel):
272
+ device_type: str = Field(default=DEFAULT_DEVICE_TYPE)
268
273
  locale: str = Field(default=DEFAULT_LOCALE)
269
- deviceLocale: str = Field(default=DEFAULT_DEVICE_LOCALE)
270
- osVersion: str = Field(default=DEFAULT_OS_VERSION)
271
- deviceName: str = Field(default=DEFAULT_DEVICE_NAME)
272
- headerUserAgent: str = Field(default=DEFAULT_USER_AGENT)
273
- appVersion: str = Field(default=DEFAULT_APP_VERSION)
274
+ device_locale: str = Field(default=DEFAULT_DEVICE_LOCALE)
275
+ os_version: str = Field(default=DEFAULT_OS_VERSION)
276
+ device_name: str = Field(default=DEFAULT_DEVICE_NAME)
277
+ header_user_agent: str = Field(default=DEFAULT_USER_AGENT)
278
+ app_version: str = Field(default=DEFAULT_APP_VERSION)
274
279
  screen: str = Field(default=DEFAULT_SCREEN)
275
280
  timezone: str = Field(default=DEFAULT_TIMEZONE)
276
281
 
@@ -278,3 +283,10 @@ class UserAgentPayload(BaseModel):
278
283
  class ReworkInviteLinkPayload(CamelModel):
279
284
  revoke_private_link: bool = True
280
285
  chat_id: int
286
+
287
+
288
+ class RegisterPayload(CamelModel):
289
+ last_name: str | None = None
290
+ first_name: str
291
+ token: str
292
+ token_type: AuthType = AuthType.REGISTER
pymax/static/enum.py CHANGED
@@ -170,6 +170,7 @@ class ElementType(StrEnum):
170
170
  class AuthType(StrEnum):
171
171
  START_AUTH = "START_AUTH"
172
172
  CHECK_CODE = "CHECK_CODE"
173
+ REGISTER = "REGISTER"
173
174
 
174
175
 
175
176
  class AccessType(StrEnum):