maxapi-python 1.1.17__py3-none-any.whl → 1.1.19__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.17
3
+ Version: 1.1.19
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
@@ -1,31 +1,31 @@
1
1
  pymax/__init__.py,sha256=6wUKKwsyxFpWG3b7kwptOvHd-w78C-ygw42iCDBYQvc,1915
2
- pymax/core.py,sha256=uSmIYVBqiO-3v0wtehObnvOEJ3n_dpG62TXTKwmdFf4,13838
2
+ pymax/core.py,sha256=QEERtU32ODmEIf1XFCDhD18a29yRW5TB97NjtusMUCg,15644
3
3
  pymax/crud.py,sha256=wmJh8MPi3L_HbYp7MJP0eXfDcnjgfkLDa9rHAmXtkow,3219
4
4
  pymax/exceptions.py,sha256=nDUNx7bM-Yjugj-qfIllcrnwLg9JpZroYqfXapjYbMQ,3178
5
5
  pymax/files.py,sha256=dRuOpvoJZWiH4xa_HVGyqQ-_Zzj-sVikElHmrPjwgs0,3166
6
6
  pymax/filters.py,sha256=4hehzyQlyxBxLiEhtSH_KQdFHx8gYaqyIOOD72EsSS0,1536
7
7
  pymax/formatter.py,sha256=OVsTwambHhXlOMd0wVECJWuB_S2wSEVKdMLCzgPcvYQ,859
8
8
  pymax/formatting.py,sha256=hhtmakfcQDzQRsAckPunnJOCKY2lFdLeIp8Yw53yY8s,2522
9
- pymax/interfaces.py,sha256=WWKNGT725GXuYneS9gCOAC6RNtySRs-BTU0fQLyh2OQ,3399
9
+ pymax/interfaces.py,sha256=OqYTiTUs6HqTkx3I3CU34q-En8nLS1Rx2hRlmEq65V4,3643
10
10
  pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
11
11
  pymax/navigation.py,sha256=4ia6RGY2pXMArboNhHkbWlWX7LtcYK1VGVXorPX0Pb4,5747
12
- pymax/payloads.py,sha256=qaafULDGBXsQ7gNFC374wZVUwN5tzJLHwkxtAmglOzU,6292
12
+ pymax/payloads.py,sha256=S1dJwDPanFfIdY_NlXN2epVyibmmL9bceltgLVmEtTA,6304
13
13
  pymax/types.py,sha256=RaLn9bUpkxO0SKbDMIHnoFeqV6gqOl2pKDNCa2LxTRI,32102
14
14
  pymax/mixins/__init__.py,sha256=xvjcq-lFVHCPss_t8xxXya0OJnsh-owlBqtUlrXSCcw,695
15
- pymax/mixins/auth.py,sha256=H4Zp3n8cwpv4Q3Mn1_Kb7Oh9DbTL7T9GcWJ6R1JN7ls,6672
15
+ pymax/mixins/auth.py,sha256=zkMkALjvb2427g1DMcvUIKsOQgw1y8d-tEo3jlyNiWQ,6744
16
16
  pymax/mixins/channel.py,sha256=dMuJRnbqZisN8kcPFCCe1sIOOBQl2uT4P49PpZXcoKE,5206
17
17
  pymax/mixins/group.py,sha256=7oa7RpiqnlcnAsnIHOfSiujNYAzUZ9lkTy9NGW5KVUE,8654
18
- pymax/mixins/handler.py,sha256=AhxIRvwftkuWN435_CXede2ZVWrDde4zkMPZtwIm5IU,3892
18
+ pymax/mixins/handler.py,sha256=TuO5bHK6qwJ-Wdh3lMg6uWaG6IwNPOUTCnMw2PkCFjA,5872
19
19
  pymax/mixins/message.py,sha256=ezU9d6r4MkYjH67gZ9SFLYPKqo4Nb6lswqDsEW5p-Bg,22329
20
20
  pymax/mixins/self.py,sha256=tDQrUdUpsCu7qGkWLtKxTfTHPHU5_r3qsn-eptHG2KY,1198
21
- pymax/mixins/socket.py,sha256=j6XTo_M3rNw-az2PfSW6oJ_YHg9M7cWARY4cXpMllDY,22256
21
+ pymax/mixins/socket.py,sha256=VsQSDyzP2xvQp-V8SgeOBn3vVQfWwi0m_wMIvlzu2lY,25517
22
22
  pymax/mixins/telemetry.py,sha256=LWr68DNQkPhAjGRDYQ5lORXxC3Yw6M9E8sF0TCNISTE,3609
23
23
  pymax/mixins/user.py,sha256=5utoK7Z-7lySOg0PEO69b6h_3kjfcnV-YydTZIdNj8g,7120
24
24
  pymax/mixins/utils.py,sha256=s3FUf3i_wjn2Gbg5YY1rWZB-90ZEGrrcUuND_MqqSTE,853
25
- pymax/mixins/websocket.py,sha256=LaL-okzhJCyS3uWV7xsCCKnuff_rooKjAoZ8vkaintY,16817
25
+ pymax/mixins/websocket.py,sha256=Rfn3PFfmey2u3e3xnebNeT9VoxBF9Dq20xM8ljaDiII,19286
26
26
  pymax/static/constant.py,sha256=Q1NrmaRj17Gdhk3FmUp3HIwrad1TDorq3wFdQlOCzN8,1027
27
27
  pymax/static/enum.py,sha256=c_QaLU0Ephe4SuKFIpwpmrf_HCutc34JJ6o4Ik1E6_g,4582
28
- maxapi_python-1.1.17.dist-info/METADATA,sha256=0gplAk5x9ujWD7fkCZu_yvwataKPPq3fMETJbwHb3HQ,6245
29
- maxapi_python-1.1.17.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
30
- maxapi_python-1.1.17.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
31
- maxapi_python-1.1.17.dist-info/RECORD,,
28
+ maxapi_python-1.1.19.dist-info/METADATA,sha256=2tRU1Um8ZR6LHzm83ze-bWs0V5FI6ru6DNYHbi4JBw8,6245
29
+ maxapi_python-1.1.19.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
30
+ maxapi_python-1.1.19.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
31
+ maxapi_python-1.1.19.dist-info/RECORD,,
pymax/core.py CHANGED
@@ -7,7 +7,7 @@ import time
7
7
  import traceback
8
8
  from collections.abc import Awaitable
9
9
  from pathlib import Path
10
- from typing import TYPE_CHECKING, Any, Literal
10
+ from typing import TYPE_CHECKING, Any, Literal, Self
11
11
 
12
12
  from typing_extensions import override
13
13
 
@@ -29,7 +29,7 @@ if TYPE_CHECKING:
29
29
  import websockets
30
30
 
31
31
  from .filters import Filter
32
- from .types import Channel, Chat, Dialog, Me, Message, User
32
+ from .types import Channel, Chat, Dialog, Me, Message, ReactionInfo, User
33
33
 
34
34
 
35
35
  logger = logging.getLogger(__name__)
@@ -143,6 +143,10 @@ class MaxClient(ApiMixin, WebSocketMixin):
143
143
  tuple[Callable[[Message], Any], Filter | None]
144
144
  ] = []
145
145
  self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
146
+ self._on_reaction_change_handlers: list[
147
+ tuple[Callable[[str, int, ReactionInfo], Any]]
148
+ ] = []
149
+ self._on_chat_update_handlers: list[tuple[Callable[[Chat], Any]]] = []
146
150
 
147
151
  self._ssl_context = ssl.create_default_context()
148
152
  self._ssl_context.set_ciphers("DEFAULT")
@@ -232,6 +236,97 @@ class MaxClient(ApiMixin, WebSocketMixin):
232
236
  self._background_tasks.add(task)
233
237
  return task
234
238
 
239
+ async def _post_login_tasks(self, sync: bool = True) -> None:
240
+ if sync:
241
+ await self._sync()
242
+
243
+ if self._on_start_handler:
244
+ self.logger.debug("Calling on_start handler")
245
+ result = self._on_start_handler()
246
+ if asyncio.iscoroutine(result):
247
+ await self._safe_execute(result, context="on_start handler")
248
+
249
+ ping_task = asyncio.create_task(self._send_interactive_ping())
250
+ ping_task.add_done_callback(self._log_task_exception)
251
+ self._background_tasks.add(ping_task)
252
+
253
+ if self._send_fake_telemetry:
254
+ telemetry_task = asyncio.create_task(self._start())
255
+ telemetry_task.add_done_callback(self._log_task_exception)
256
+ self._background_tasks.add(telemetry_task)
257
+
258
+ async def _cleanup_client(self):
259
+ for task in list(self._background_tasks):
260
+ task.cancel()
261
+ try:
262
+ await task
263
+ except asyncio.CancelledError:
264
+ pass
265
+ except Exception:
266
+ self.logger.debug(
267
+ "Background task raised during cancellation", exc_info=True
268
+ )
269
+ self._background_tasks.discard(task)
270
+
271
+ if self._recv_task:
272
+ self._recv_task.cancel()
273
+ with contextlib.suppress(asyncio.CancelledError):
274
+ await self._recv_task
275
+ self._recv_task = None
276
+
277
+ if self._outgoing_task:
278
+ self._outgoing_task.cancel()
279
+ with contextlib.suppress(asyncio.CancelledError):
280
+ await self._outgoing_task
281
+ self._outgoing_task = None
282
+
283
+ for fut in self._pending.values():
284
+ if not fut.done():
285
+ fut.set_exception(WebSocketNotConnectedError)
286
+ self._pending.clear()
287
+
288
+ if self._ws:
289
+ try:
290
+ await self._ws.close()
291
+ except Exception:
292
+ self.logger.debug("Error closing ws during cleanup", exc_info=True)
293
+ self._ws = None
294
+
295
+ self.is_connected = False
296
+ self.logger.info("Client start() cleaned up")
297
+
298
+ async def login_with_code(
299
+ self, temp_token: str, code: str, start: bool = False
300
+ ) -> None:
301
+ """
302
+ Завершает кастомный login flow: отправляет код, сохраняет токен и запускает пост-логин задачи.
303
+
304
+ Args:
305
+ temp_token (str): Временный токен, полученный из request_code()
306
+ code (str): Код, введённый пользователем
307
+
308
+ Returns:
309
+ str: Токен для входа
310
+ """
311
+ resp = await self._send_code(code, temp_token)
312
+ token = resp.get("tokenAttrs", {}).get("LOGIN", {}).get("token")
313
+ self._token = token
314
+ self._database.update_auth_token(self._device_id, token)
315
+ if start:
316
+ while True:
317
+ try:
318
+ await self._post_login_tasks()
319
+ await self._wait_forever()
320
+ except Exception:
321
+ self.logger.exception("Error during post-login tasks")
322
+ finally:
323
+ await self._cleanup_client()
324
+
325
+ self.logger.info("Reconnecting after post-login tasks failure")
326
+ await asyncio.sleep(self.reconnect_delay)
327
+ else:
328
+ self.logger.info("Login successful, token saved to database, exiting...")
329
+
235
330
  async def start(self) -> None:
236
331
  """
237
332
  Запускает клиент, подключается к WebSocket, авторизует
@@ -242,7 +337,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
242
337
  while True:
243
338
  try:
244
339
  self.logger.info("Client starting")
245
- await self._connect(self.user_agent)
340
+ await self.connect(self.user_agent)
246
341
 
247
342
  if self.registration:
248
343
  if not self.first_name:
@@ -257,70 +352,19 @@ class MaxClient(ApiMixin, WebSocketMixin):
257
352
  else:
258
353
  await self._sync()
259
354
 
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)
355
+ await self._post_login_tasks(sync=False)
274
356
 
275
357
  await self._wait_forever()
276
358
  self.logger.info("WebSocket closed (wait_forever exited)")
277
359
 
278
- except Exception:
360
+ except Exception as e:
279
361
  self.logger.exception("Client start iteration failed")
362
+ raise e
280
363
 
281
364
  finally:
282
365
  self.logger.debug("Cleaning up background tasks and pending futures")
283
366
 
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")
367
+ await self._cleanup_client()
324
368
 
325
369
  if not self.reconnect:
326
370
  self.logger.info("Reconnect disabled — exiting start()")
@@ -329,6 +373,18 @@ class MaxClient(ApiMixin, WebSocketMixin):
329
373
  self.logger.info("Reconnect enabled — restarting client")
330
374
  await asyncio.sleep(self.reconnect_delay)
331
375
 
376
+ async def idle(self):
377
+ await asyncio.Event().wait()
378
+
379
+ async def __aenter__(self) -> Self:
380
+ self._create_safe_task(self.start(), name="start")
381
+ while not self.is_connected:
382
+ await asyncio.sleep(0.05)
383
+ return self
384
+
385
+ async def __aexit__(self, exc_type, exc, tb) -> None:
386
+ await self.close()
387
+
332
388
 
333
389
  class SocketMaxClient(SocketMixin, MaxClient):
334
390
  @override
pymax/interfaces.py CHANGED
@@ -18,6 +18,8 @@ from .types import Channel, Chat, Dialog, Me, Message, User
18
18
  if TYPE_CHECKING:
19
19
  from uuid import UUID
20
20
 
21
+ from pymax.types import ReactionInfo
22
+
21
23
  from .crud import Database
22
24
 
23
25
 
@@ -69,6 +71,10 @@ class ClientProtocol(ABC):
69
71
  self._on_message_delete_handlers: list[
70
72
  tuple[Callable[[Message], Any], Filter | None]
71
73
  ] = []
74
+ self._on_reaction_change_handlers: list[
75
+ tuple[Callable[[str, int, ReactionInfo], Any]]
76
+ ] = []
77
+ self._on_chat_update_handlers: list[tuple[Callable[[Chat], Any]]] = []
72
78
  self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
73
79
  self._background_tasks: set[asyncio.Task[Any]] = set()
74
80
  self._ssl_context: ssl.SSLContext
pymax/mixins/auth.py CHANGED
@@ -15,71 +15,73 @@ class AuthMixin(ClientProtocol):
15
15
  def _check_phone(self) -> bool:
16
16
  return bool(re.match(PHONE_REGEX, self.phone))
17
17
 
18
- async def _request_code(
19
- self, phone: str, language: str = "ru"
20
- ) -> dict[str, int | str]:
21
- try:
22
- self.logger.info("Requesting auth code")
18
+ async def request_code(self, phone: str, language: str = "ru") -> str:
19
+ """
20
+ Запрашивает код аутентификации для указанного номера телефона и возвращает временный токен.
23
21
 
24
- payload = RequestCodePayload(
25
- phone=phone, type=AuthType.START_AUTH, language=language
26
- ).model_dump(by_alias=True)
22
+ Note:
23
+ Использовать только в кастомном login flow.
27
24
 
28
- data = await self._send_and_wait(
29
- opcode=Opcode.AUTH_REQUEST, payload=payload
30
- )
31
- if data.get("payload", {}).get("error"):
32
- MixinsUtils.handle_error(data)
25
+ Args:
26
+ phone (str): Номер телефона в международном формате.
27
+ language (str, optional): Язык для сообщения с кодом. По умолчанию "ru".
33
28
 
34
- self.logger.debug(
35
- "Code request response opcode=%s seq=%s",
36
- data.get("opcode"),
37
- data.get("seq"),
38
- )
39
- payload_data = data.get("payload")
40
- if isinstance(payload_data, dict):
41
- return payload_data
42
- else:
43
- self.logger.error("Invalid payload data received")
44
- raise ValueError("Invalid payload data received")
45
- except Exception:
46
- self.logger.error("Request code failed", exc_info=True)
47
- raise RuntimeError("Request code failed")
29
+ Returns:
30
+ str: Временный токен для дальнейшей аутентификации.
31
+ """
32
+ self.logger.info("Requesting auth code")
33
+
34
+ payload = RequestCodePayload(
35
+ phone=phone, type=AuthType.START_AUTH, language=language
36
+ ).model_dump(by_alias=True)
37
+
38
+ data = await self._send_and_wait(opcode=Opcode.AUTH_REQUEST, payload=payload)
39
+
40
+ if data.get("payload", {}).get("error"):
41
+ MixinsUtils.handle_error(data)
42
+
43
+ self.logger.debug(
44
+ "Code request response opcode=%s seq=%s",
45
+ data.get("opcode"),
46
+ data.get("seq"),
47
+ )
48
+ payload_data = data.get("payload")
49
+ if isinstance(payload_data, dict):
50
+ return payload_data["token"]
51
+ else:
52
+ self.logger.error("Invalid payload data received")
53
+ raise ValueError("Invalid payload data received")
48
54
 
49
55
  async def _send_code(self, code: str, token: str) -> dict[str, Any]:
50
- try:
51
- self.logger.info("Sending verification code")
56
+ self.logger.info("Sending verification code")
52
57
 
53
- payload = SendCodePayload(
54
- token=token,
55
- verify_code=code,
56
- auth_token_type=AuthType.CHECK_CODE,
57
- ).model_dump(by_alias=True)
58
+ payload = SendCodePayload(
59
+ token=token,
60
+ verify_code=code,
61
+ auth_token_type=AuthType.CHECK_CODE,
62
+ ).model_dump(by_alias=True)
58
63
 
59
- data = await self._send_and_wait(opcode=Opcode.AUTH, payload=payload)
60
- if data.get("payload", {}).get("error"):
61
- MixinsUtils.handle_error(data)
64
+ data = await self._send_and_wait(opcode=Opcode.AUTH, payload=payload)
62
65
 
63
- self.logger.debug(
64
- "Send code response opcode=%s seq=%s",
65
- data.get("opcode"),
66
- data.get("seq"),
67
- )
68
- payload_data = data.get("payload")
69
- if isinstance(payload_data, dict):
70
- return payload_data
71
- else:
72
- self.logger.error("Invalid payload data received")
73
- raise ValueError("Invalid payload data received")
74
- except Exception:
75
- self.logger.error("Send code failed", exc_info=True)
76
- raise RuntimeError("Send code failed")
66
+ if data.get("payload", {}).get("error"):
67
+ MixinsUtils.handle_error(data)
68
+
69
+ self.logger.debug(
70
+ "Send code response opcode=%s seq=%s",
71
+ data.get("opcode"),
72
+ data.get("seq"),
73
+ )
74
+ payload_data = data.get("payload")
75
+ if isinstance(payload_data, dict):
76
+ return payload_data
77
+ else:
78
+ self.logger.error("Invalid payload data received")
79
+ raise ValueError("Invalid payload data received")
77
80
 
78
81
  async def _login(self) -> None:
79
82
  self.logger.info("Starting login flow")
80
83
 
81
- request_code_payload = await self._request_code(self.phone)
82
- temp_token = request_code_payload.get("token")
84
+ temp_token = await self.request_code(self.phone)
83
85
  if not temp_token or not isinstance(temp_token, str):
84
86
  self.logger.critical("Failed to request code: token missing")
85
87
  raise ValueError("Failed to request code")
@@ -136,7 +138,7 @@ class AuthMixin(ClientProtocol):
136
138
  async def _register(self, first_name: str, last_name: str | None = None) -> None:
137
139
  self.logger.info("Starting registration flow")
138
140
 
139
- request_code_payload = await self._request_code(self.phone)
141
+ request_code_payload = await self.request_code(self.phone)
140
142
  temp_token = request_code_payload.get("token")
141
143
 
142
144
  if not temp_token or not isinstance(temp_token, str):
pymax/mixins/handler.py CHANGED
@@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable
2
2
  from typing import Any
3
3
 
4
4
  from pymax.interfaces import ClientProtocol, Filter
5
- from pymax.types import Message
5
+ from pymax.types import Chat, Message, ReactionInfo
6
6
 
7
7
 
8
8
  class HandlerMixin(ClientProtocol):
@@ -85,6 +85,39 @@ class HandlerMixin(ClientProtocol):
85
85
 
86
86
  return decorator
87
87
 
88
+ def on_reaction_change(
89
+ self,
90
+ handler: Callable[[str, int, ReactionInfo], Any | Awaitable[Any]],
91
+ ) -> Callable[[str, int, ReactionInfo], Any | Awaitable[Any]]:
92
+ """
93
+ Устанавливает обработчик изменения реакций на сообщения.
94
+
95
+ Args:
96
+ handler: Функция или coroutine с аргументами (message_id: str, chat_id: int, reaction_info: ReactionInfo).
97
+
98
+ Returns:
99
+ Установленный обработчик.
100
+ """
101
+ self._on_reaction_change_handlers.append((handler,))
102
+ self.logger.debug("on_reaction_change handler set: %r", handler)
103
+ return handler
104
+
105
+ def on_chat_update(
106
+ self, handler: Callable[[Chat], Any | Awaitable[Any]]
107
+ ) -> Callable[[Chat], Any | Awaitable[Any]]:
108
+ """
109
+ Устанавливает обработчик обновления информации о чате.
110
+
111
+ Args:
112
+ handler: Функция или coroutine с аргументом (chat: Chat).
113
+
114
+ Returns:
115
+ Установленный обработчик.
116
+ """
117
+ self._on_chat_update_handlers.append((handler,))
118
+ self.logger.debug("on_chat_update handler set: %r", handler)
119
+ return handler
120
+
88
121
  def on_start(
89
122
  self, handler: Callable[[], Any | Awaitable[Any]]
90
123
  ) -> Callable[[], Any | Awaitable[Any]]:
@@ -116,3 +149,18 @@ class HandlerMixin(ClientProtocol):
116
149
  self.logger.debug("add_on_start_handler (alias) used")
117
150
  self._on_start_handler = handler
118
151
  return handler
152
+
153
+ def add_reaction_change_handler(
154
+ self,
155
+ handler: Callable[[str, int, ReactionInfo], Any | Awaitable[Any]],
156
+ ) -> Callable[[str, int, ReactionInfo], Any | Awaitable[Any]]:
157
+ self.logger.debug("add_reaction_change_handler (alias) used")
158
+ self._on_reaction_change_handlers.append((handler,))
159
+ return handler
160
+
161
+ def add_chat_update_handler(
162
+ self, handler: Callable[[Chat], Any | Awaitable[Any]]
163
+ ) -> Callable[[Chat], Any | Awaitable[Any]]:
164
+ self.logger.debug("add_chat_update_handler (alias) used")
165
+ self._on_chat_update_handlers.append((handler,))
166
+ return handler
pymax/mixins/socket.py CHANGED
@@ -11,7 +11,7 @@ import msgpack
11
11
  from typing_extensions import override
12
12
 
13
13
  from pymax.exceptions import Error, SocketNotConnectedError, SocketSendError
14
- from pymax.filters import Filter, Message
14
+ from pymax.filters import Filter
15
15
  from pymax.interfaces import ClientProtocol
16
16
  from pymax.payloads import BaseWebSocketMessage, SyncPayload, UserAgentPayload
17
17
  from pymax.static.constant import (
@@ -19,8 +19,16 @@ from pymax.static.constant import (
19
19
  DEFAULT_TIMEOUT,
20
20
  RECV_LOOP_BACKOFF_DELAY,
21
21
  )
22
- from pymax.static.enum import MessageStatus, Opcode
23
- from pymax.types import Channel, Chat, Dialog, Me
22
+ from pymax.static.enum import ChatType, MessageStatus, Opcode
23
+ from pymax.types import (
24
+ Channel,
25
+ Chat,
26
+ Dialog,
27
+ Me,
28
+ Message,
29
+ ReactionCounter,
30
+ ReactionInfo,
31
+ )
24
32
 
25
33
 
26
34
  class SocketMixin(ClientProtocol):
@@ -276,6 +284,67 @@ Socket connections may be unstable, SSL issues are possible.
276
284
  )
277
285
  except Exception:
278
286
  self.logger.exception("Error in on_message_handler")
287
+
288
+ if (
289
+ data_item.get("opcode")
290
+ == Opcode.NOTIF_MSG_REACTIONS_CHANGED
291
+ ):
292
+ try:
293
+ for (
294
+ reaction_handler,
295
+ ) in self._on_reaction_change_handlers:
296
+ payload = data_item.get("payload", {})
297
+
298
+ chat_id = payload.get("chatId")
299
+ message_id = payload.get("messageId")
300
+
301
+ total_count = payload.get("totalCount")
302
+ your_reaction = payload.get("yourReaction")
303
+ counters = [
304
+ ReactionCounter.from_dict(c)
305
+ for c in payload.get("counters", [])
306
+ ]
307
+
308
+ if (
309
+ chat_id
310
+ and message_id
311
+ and (
312
+ total_count is not None
313
+ or your_reaction
314
+ or counters
315
+ )
316
+ ):
317
+ reaction_info = ReactionInfo(
318
+ total_count=total_count,
319
+ your_reaction=your_reaction,
320
+ counters=counters,
321
+ )
322
+ result = reaction_handler(
323
+ message_id, chat_id, reaction_info
324
+ )
325
+ if asyncio.iscoroutine(result):
326
+ await result
327
+
328
+ except Exception as e:
329
+ self.logger.exception(
330
+ "Error in on_reaction_change_handler: %s", e
331
+ )
332
+
333
+ if data_item.get("opcode") == Opcode.NOTIF_CHAT:
334
+ try:
335
+ for (
336
+ chat_update_handler,
337
+ ) in self._on_chat_update_handlers:
338
+ payload = data_item.get("payload", {})
339
+ chat = Chat.from_dict(payload.get("chat", {}))
340
+ if chat:
341
+ result = chat_update_handler(chat)
342
+ if asyncio.iscoroutine(result):
343
+ await result
344
+ except Exception as e:
345
+ self.logger.exception(
346
+ "Error in on_chat_update_handler: %s", e
347
+ )
279
348
  except asyncio.CancelledError:
280
349
  self.logger.debug("Recv loop cancelled")
281
350
  break
pymax/mixins/websocket.py CHANGED
@@ -10,6 +10,7 @@ from typing_extensions import override
10
10
  from pymax.exceptions import LoginError, WebSocketNotConnectedError
11
11
  from pymax.filters import Filter
12
12
  from pymax.interfaces import ClientProtocol
13
+ from pymax.mixins.utils import MixinsUtils
13
14
  from pymax.payloads import BaseWebSocketMessage, SyncPayload, UserAgentPayload
14
15
  from pymax.static.constant import (
15
16
  DEFAULT_PING_INTERVAL,
@@ -18,7 +19,15 @@ from pymax.static.constant import (
18
19
  WEBSOCKET_ORIGIN,
19
20
  )
20
21
  from pymax.static.enum import ChatType, MessageStatus, Opcode
21
- from pymax.types import Channel, Chat, Dialog, Me, Message
22
+ from pymax.types import (
23
+ Channel,
24
+ Chat,
25
+ Dialog,
26
+ Me,
27
+ Message,
28
+ ReactionCounter,
29
+ ReactionInfo,
30
+ )
22
31
 
23
32
 
24
33
  class WebSocketMixin(ClientProtocol):
@@ -59,45 +68,54 @@ class WebSocketMixin(ClientProtocol):
59
68
  self.logger.warning("Interactive ping failed", exc_info=True)
60
69
  await asyncio.sleep(DEFAULT_PING_INTERVAL)
61
70
 
62
- async def _connect(self, user_agent: UserAgentPayload) -> dict[str, Any]:
63
- try:
64
- self.logger.info("Connecting to WebSocket %s", self.uri)
65
- self._ws = await websockets.connect(
66
- self.uri,
67
- origin=WEBSOCKET_ORIGIN,
68
- user_agent_header=user_agent.header_user_agent,
69
- proxy=self.proxy,
70
- )
71
- self.is_connected = True
72
- self._incoming = asyncio.Queue()
73
- self._outgoing = asyncio.Queue()
74
- self._pending = {}
75
- self._recv_task = asyncio.create_task(self._recv_loop())
76
- self._outgoing_task = asyncio.create_task(self._outgoing_loop())
77
- self.logger.info("WebSocket connected, starting handshake")
78
- return await self._handshake(user_agent)
79
- except Exception as e:
80
- self.logger.error("Failed to connect: %s", e, exc_info=True)
81
- raise ConnectionError(f"Failed to connect: {e}")
71
+ async def connect(
72
+ self, user_agent: UserAgentPayload | None = None
73
+ ) -> dict[str, Any] | None:
74
+ """
75
+ Устанавливает соединение WebSocket с сервером и выполняет handshake.
76
+ """
77
+ if user_agent is None:
78
+ user_agent = UserAgentPayload()
79
+
80
+ self.logger.info("Connecting to WebSocket %s", self.uri)
81
+
82
+ if self._ws is not None or self.is_connected:
83
+ self.logger.warning("WebSocket already connected")
84
+ return
85
+
86
+ self._ws = await websockets.connect(
87
+ self.uri,
88
+ origin=WEBSOCKET_ORIGIN,
89
+ user_agent_header=user_agent.header_user_agent,
90
+ proxy=self.proxy,
91
+ )
92
+ self.is_connected = True
93
+ self._incoming = asyncio.Queue()
94
+ self._outgoing = asyncio.Queue()
95
+ self._pending = {}
96
+ self._recv_task = asyncio.create_task(self._recv_loop())
97
+ self._outgoing_task = asyncio.create_task(self._outgoing_loop())
98
+ self.logger.info("WebSocket connected, starting handshake")
99
+ return await self._handshake(user_agent)
82
100
 
83
101
  async def _handshake(self, user_agent: UserAgentPayload) -> dict[str, Any]:
84
- try:
85
- self.logger.debug(
86
- "Sending handshake with user_agent keys=%s",
87
- user_agent.model_dump().keys(),
88
- )
89
- resp = await self._send_and_wait(
90
- opcode=Opcode.SESSION_INIT,
91
- payload={
92
- "deviceId": str(self._device_id),
93
- "userAgent": user_agent,
94
- },
95
- )
96
- self.logger.info("Handshake completed")
97
- return resp
98
- except Exception as e:
99
- self.logger.error("Handshake failed: %s", e, exc_info=True)
100
- raise ConnectionError(f"Handshake failed: {e}")
102
+ self.logger.debug(
103
+ "Sending handshake with user_agent keys=%s",
104
+ user_agent.model_dump().keys(),
105
+ )
106
+ resp = await self._send_and_wait(
107
+ opcode=Opcode.SESSION_INIT,
108
+ payload={
109
+ "deviceId": str(self._device_id),
110
+ "userAgent": user_agent, # TODO: вынести в статик мб
111
+ },
112
+ )
113
+
114
+ if resp.get("payload", {}).get("error"):
115
+ MixinsUtils.handle_error(resp)
116
+
117
+ self.logger.info("Handshake completed")
118
+ return resp
101
119
 
102
120
  async def _process_message_handler(
103
121
  self,
@@ -130,7 +148,6 @@ class WebSocketMixin(ClientProtocol):
130
148
  except Exception:
131
149
  self.logger.warning("JSON parse error", exc_info=True)
132
150
  continue
133
-
134
151
  seq = data.get("seq")
135
152
  fut = self._pending.get(seq) if isinstance(seq, int) else None
136
153
 
@@ -161,9 +178,10 @@ class WebSocketMixin(ClientProtocol):
161
178
  except Exception:
162
179
  self.logger.exception("Error handling file upload notification")
163
180
 
164
- if (
165
- data.get("opcode") == Opcode.NOTIF_MESSAGE.value
166
- and self._on_message_handlers
181
+ if data.get("opcode") == Opcode.NOTIF_MESSAGE.value and (
182
+ self._on_message_handlers
183
+ or self._on_message_edit_handlers
184
+ or self._on_message_delete_handlers
167
185
  ):
168
186
  try:
169
187
  for handler, filter in self._on_message_handlers:
@@ -197,6 +215,62 @@ class WebSocketMixin(ClientProtocol):
197
215
  except Exception:
198
216
  self.logger.exception("Error in on_message_handler")
199
217
 
218
+ if data.get("opcode") == Opcode.NOTIF_MSG_REACTIONS_CHANGED:
219
+ try:
220
+ for (
221
+ reaction_handler,
222
+ ) in self._on_reaction_change_handlers:
223
+ payload = data.get("payload", {})
224
+
225
+ chat_id = payload.get("chatId")
226
+ message_id = payload.get("messageId")
227
+
228
+ total_count = payload.get("totalCount")
229
+ your_reaction = payload.get("yourReaction")
230
+ counters = [
231
+ ReactionCounter.from_dict(c)
232
+ for c in payload.get("counters", [])
233
+ ]
234
+
235
+ if (
236
+ chat_id
237
+ and message_id
238
+ and (
239
+ total_count is not None
240
+ or your_reaction
241
+ or counters
242
+ )
243
+ ):
244
+ reaction_info = ReactionInfo(
245
+ total_count=total_count,
246
+ your_reaction=your_reaction,
247
+ counters=counters,
248
+ )
249
+ result = reaction_handler(
250
+ message_id, chat_id, reaction_info
251
+ )
252
+ if asyncio.iscoroutine(result):
253
+ await result
254
+
255
+ except Exception as e:
256
+ self.logger.exception(
257
+ "Error in on_reaction_change_handler: %s", e
258
+ )
259
+
260
+ if data.get("opcode") == Opcode.NOTIF_CHAT:
261
+ try:
262
+ for (chat_update_handler,) in self._on_chat_update_handlers:
263
+ payload = data.get("payload", {})
264
+ chat = Chat.from_dict(payload.get("chat", {}))
265
+ if chat:
266
+ result = chat_update_handler(chat)
267
+ if asyncio.iscoroutine(result):
268
+ await result
269
+ except Exception as e:
270
+ self.logger.exception(
271
+ "Error in on_chat_update_handler: %s", e
272
+ )
273
+
200
274
  except websockets.exceptions.ConnectionClosed:
201
275
  self.logger.info("WebSocket connection closed; exiting recv loop")
202
276
  for fut in self._pending.values():
@@ -371,28 +445,12 @@ class WebSocketMixin(ClientProtocol):
371
445
  drafts_sync=0,
372
446
  chats_count=40,
373
447
  ).model_dump(by_alias=True)
374
-
375
448
  try:
376
449
  data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload)
377
450
  raw_payload = data.get("payload", {})
378
451
 
379
452
  if error := raw_payload.get("error"):
380
- self.logger.error("Sync error: %s", error)
381
-
382
- if error == "login.token":
383
- if self._ws:
384
- await self._ws.close()
385
- self.is_connected = False
386
- self._ws = None
387
- self._recv_task = None
388
- raise LoginError(
389
- raw_payload.get("error", "unknown"),
390
- raw_payload.get("message", "No message provided"),
391
- raw_payload.get("title", "Login Error"),
392
- raw_payload.get("localizedMessage"),
393
- )
394
-
395
- return
453
+ MixinsUtils.handle_error(data)
396
454
 
397
455
  for raw_chat in raw_payload.get("chats", []):
398
456
  try:
pymax/payloads.py CHANGED
@@ -1,4 +1,4 @@
1
- from typing import Any, Final, Literal
1
+ from typing import Any, Literal
2
2
 
3
3
  from pydantic import AliasChoices, BaseModel, Field
4
4
 
@@ -30,7 +30,7 @@ class CamelModel(BaseModel):
30
30
 
31
31
 
32
32
  class BaseWebSocketMessage(BaseModel):
33
- ver: Final[int] = 11
33
+ ver: Literal[10, 11] = 11
34
34
  cmd: int
35
35
  seq: int
36
36
  opcode: int
@@ -195,14 +195,14 @@ class ChangeGroupProfilePayload(CamelModel):
195
195
 
196
196
 
197
197
  class GetGroupMembersPayload(CamelModel):
198
- type: Final[str] = "MEMBER"
198
+ type: Literal["MEMBER"] = "MEMBER"
199
199
  marker: int
200
200
  chat_id: int
201
201
  count: int
202
202
 
203
203
 
204
204
  class SearchGroupMembersPayload(CamelModel):
205
- type: Final[str] = "MEMBER"
205
+ type: Literal["MEMBER"] = "MEMBER"
206
206
  query: str
207
207
  chat_id: int
208
208