maxapi-python 1.2.3__py3-none-any.whl → 1.2.5__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.
pymax/mixins/socket.py CHANGED
@@ -12,8 +12,9 @@ from typing_extensions import override
12
12
 
13
13
  from pymax.exceptions import Error, SocketNotConnectedError, SocketSendError
14
14
  from pymax.filters import BaseFilter
15
- from pymax.interfaces import ClientProtocol
15
+ from pymax.interfaces import BaseTransport
16
16
  from pymax.payloads import BaseWebSocketMessage, SyncPayload, UserAgentPayload
17
+ from pymax.protocols import ClientProtocol
17
18
  from pymax.static.constant import (
18
19
  DEFAULT_PING_INTERVAL,
19
20
  DEFAULT_TIMEOUT,
@@ -32,7 +33,7 @@ from pymax.types import (
32
33
  )
33
34
 
34
35
 
35
- class SocketMixin(ClientProtocol):
36
+ class SocketMixin(BaseTransport):
36
37
  @property
37
38
  def sock(self) -> socket.socket:
38
39
  if self._socket is None or not self.is_connected:
@@ -83,7 +84,7 @@ class SocketMixin(ClientProtocol):
83
84
  ) -> bytes:
84
85
  ver_b = ver.to_bytes(1, "big")
85
86
  cmd_b = cmd.to_bytes(2, "big")
86
- seq_b = seq.to_bytes(1, "big")
87
+ seq_b = (seq % 256).to_bytes(1, "big")
87
88
  opcode_b = opcode.to_bytes(2, "big")
88
89
  payload_bytes: bytes | None = msgpack.packb(payload)
89
90
  if payload_bytes is None:
@@ -129,25 +130,6 @@ Socket connections may be unstable, SSL issues are possible.
129
130
  self.logger.info("Socket connected, starting handshake")
130
131
  return await self._handshake(user_agent)
131
132
 
132
- async def _handshake(self, user_agent: UserAgentPayload) -> dict[str, Any]:
133
- try:
134
- self.logger.debug(
135
- "Sending handshake with user_agent keys=%s",
136
- user_agent.model_dump().keys(),
137
- )
138
- resp = await self._send_and_wait(
139
- opcode=Opcode.SESSION_INIT,
140
- payload={
141
- "deviceId": str(self._device_id),
142
- "userAgent": user_agent,
143
- },
144
- )
145
- self.logger.info("Handshake completed")
146
- return resp
147
- except Exception as e:
148
- self.logger.error("Handshake failed: %s", e, exc_info=True)
149
- raise ConnectionError(f"Handshake failed: {e}")
150
-
151
133
  def _recv_exactly(self, sock: socket.socket, n: int) -> bytes:
152
134
  buf = bytearray()
153
135
  while len(buf) < n:
@@ -214,116 +196,6 @@ Socket connections may be unstable, SSL issues are possible.
214
196
  else [data]
215
197
  )
216
198
 
217
- def _handle_pending(self, seq: int | None, data: dict) -> bool:
218
- if isinstance(seq, int):
219
- fut = self._pending.get(seq)
220
- if fut and not fut.done():
221
- fut.set_result(data)
222
- self.logger.debug("Matched response for pending seq=%s", seq)
223
- return True
224
- return False
225
-
226
- async def _handle_incoming_queue(self, data: dict[str, Any]) -> None:
227
- if self._incoming:
228
- try:
229
- self._incoming.put_nowait(data)
230
- except asyncio.QueueFull:
231
- self.logger.warning(
232
- "Incoming queue full; dropping message seq=%s", data.get("seq")
233
- )
234
-
235
- async def _handle_file_upload(self, data: dict[str, Any]) -> None:
236
- if data.get("opcode") != Opcode.NOTIF_ATTACH:
237
- return
238
- payload = data.get("payload", {})
239
- for key in ("fileId", "videoId"):
240
- id_ = payload.get(key)
241
- if id_ is not None:
242
- fut = self._file_upload_waiters.pop(id_, None)
243
- if fut and not fut.done():
244
- fut.set_result(data)
245
- self.logger.debug("Fulfilled file upload waiter for %s=%s", key, id_)
246
-
247
- async def _handle_message_notifications(self, data: dict) -> None:
248
- if data.get("opcode") != Opcode.NOTIF_MESSAGE.value:
249
- return
250
- payload = data.get("payload", {})
251
- msg = Message.from_dict(payload)
252
- if not msg:
253
- return
254
- handlers_map = {
255
- MessageStatus.EDITED: self._on_message_edit_handlers,
256
- MessageStatus.REMOVED: self._on_message_delete_handlers,
257
- }
258
- if msg.status and msg.status in handlers_map:
259
- for handler, filter in handlers_map[msg.status]:
260
- await self._process_message_handler(handler, filter, msg)
261
- for handler, filter in self._on_message_handlers:
262
- await self._process_message_handler(handler, filter, msg)
263
-
264
- async def _handle_reactions(self, data: dict):
265
- if data.get("opcode") != Opcode.NOTIF_MSG_REACTIONS_CHANGED:
266
- return
267
-
268
- payload = data.get("payload", {})
269
- chat_id = payload.get("chatId")
270
- message_id = payload.get("messageId")
271
-
272
- if not (chat_id and message_id):
273
- return
274
-
275
- total_count = payload.get("totalCount")
276
- your_reaction = payload.get("yourReaction")
277
- counters = [ReactionCounter.from_dict(c) for c in payload.get("counters", [])]
278
-
279
- reaction_info = ReactionInfo(
280
- total_count=total_count,
281
- your_reaction=your_reaction,
282
- counters=counters,
283
- )
284
-
285
- for handler in self._on_reaction_change_handlers:
286
- try:
287
- result = handler(message_id, chat_id, reaction_info)
288
- if asyncio.iscoroutine(result):
289
- await result
290
- except Exception as e:
291
- self.logger.exception("Error in on_reaction_change_handler: %s", e)
292
-
293
- async def _handle_chat_updates(self, data: dict) -> None:
294
- if data.get("opcode") != Opcode.NOTIF_CHAT:
295
- return
296
-
297
- payload = data.get("payload", {})
298
- chat_data = payload.get("chat", {})
299
- chat = Chat.from_dict(chat_data)
300
- if not chat:
301
- return
302
-
303
- for handler in self._on_chat_update_handlers:
304
- try:
305
- result = handler(chat)
306
- if asyncio.iscoroutine(result):
307
- await result
308
- except Exception as e:
309
- self.logger.exception("Error in on_chat_update_handler: %s", e)
310
-
311
- async def _handle_raw_receive(self, data: dict[str, Any]) -> None:
312
- for handler in self._on_raw_receive_handlers:
313
- try:
314
- result = handler(data)
315
- if asyncio.iscoroutine(result):
316
- await result
317
- except Exception as e:
318
- self.logger.exception("Error in on_raw_receive_handler: %s", e)
319
-
320
- async def _dispatch_incoming(self, data: dict[str, Any]) -> None:
321
- await self._handle_raw_receive(data)
322
- await self._handle_file_upload(data)
323
- await self._handle_message_notifications(data)
324
- await self._handle_reactions(data)
325
- await self._handle_chat_updates(data)
326
-
327
199
  async def _recv_loop(self) -> None:
328
200
  if self._socket is None:
329
201
  self.logger.warning("Recv loop started without socket instance")
@@ -347,7 +219,7 @@ Socket connections may be unstable, SSL issues are possible.
347
219
  for data_item in datas:
348
220
  seq = data_item.get("seq")
349
221
 
350
- if self._handle_pending(seq, data_item):
222
+ if self._handle_pending(seq % 256 if seq is not None else None, data_item):
351
223
  continue
352
224
 
353
225
  if self._incoming is not None:
@@ -362,57 +234,6 @@ Socket connections may be unstable, SSL issues are possible.
362
234
  self.logger.exception("Error in recv_loop; backing off briefly")
363
235
  await asyncio.sleep(RECV_LOOP_BACKOFF_DELAY)
364
236
 
365
- def _log_task_exception(self, fut: asyncio.Future[Any]) -> None:
366
- try:
367
- fut.result()
368
- except asyncio.CancelledError:
369
- pass
370
- except Exception as e:
371
- self.logger.exception("Error getting task exception: %s", e)
372
- pass
373
-
374
- async def _process_message_handler(
375
- self,
376
- handler: Callable[[Message], Any],
377
- filter: BaseFilter[Message] | None,
378
- message: Message,
379
- ) -> None:
380
- if filter is not None and not filter(message):
381
- return
382
-
383
- result = handler(message)
384
- if asyncio.iscoroutine(result):
385
- task = asyncio.create_task(result)
386
- task.add_done_callback(self._log_task_exception)
387
- self._background_tasks.add(task)
388
-
389
- async def _send_interactive_ping(self) -> None:
390
- while self.is_connected:
391
- try:
392
- await self._send_and_wait(
393
- opcode=Opcode.PING,
394
- payload={"interactive": True},
395
- cmd=0,
396
- )
397
- self.logger.debug("Interactive ping sent successfully (socket)")
398
- except Exception:
399
- self.logger.warning("Interactive ping failed (socket)", exc_info=True)
400
- await asyncio.sleep(DEFAULT_PING_INTERVAL)
401
-
402
- def _make_message(
403
- self, opcode: Opcode, payload: dict[str, Any], cmd: int = 0
404
- ) -> dict[str, Any]:
405
- self._seq += 1
406
- msg = BaseWebSocketMessage(
407
- ver=10,
408
- cmd=cmd,
409
- seq=self._seq,
410
- opcode=opcode.value,
411
- payload=payload,
412
- ).model_dump(by_alias=True)
413
- self.logger.debug("make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq)
414
- return msg
415
-
416
237
  @override
417
238
  async def _send_and_wait(
418
239
  self,
@@ -428,7 +249,13 @@ Socket connections may be unstable, SSL issues are possible.
428
249
  msg = self._make_message(opcode, payload, cmd)
429
250
  loop = asyncio.get_running_loop()
430
251
  fut: asyncio.Future[dict[str, Any]] = loop.create_future()
431
- self._pending[msg["seq"]] = fut
252
+ seq_key = msg["seq"] % 256
253
+
254
+ old_fut = self._pending.get(seq_key)
255
+ if old_fut and not old_fut.done():
256
+ old_fut.cancel()
257
+
258
+ self._pending[seq_key] = fut
432
259
  try:
433
260
  self.logger.debug(
434
261
  "Sending frame opcode=%s cmd=%s seq=%s",
@@ -461,166 +288,15 @@ Socket connections may be unstable, SSL issues are possible.
461
288
  self.logger.exception("Reconnect failed")
462
289
  raise exc from conn_err
463
290
  raise SocketNotConnectedError from conn_err
291
+ except asyncio.TimeoutError:
292
+ self.logger.exception("Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"])
293
+ raise SocketSendError from None
464
294
  except Exception as exc:
465
295
  self.logger.exception("Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"])
466
296
  raise SocketSendError from exc
467
297
 
468
298
  finally:
469
- self._pending.pop(msg["seq"], None)
470
-
471
- async def _outgoing_loop(self) -> None:
472
- while self.is_connected:
473
- try:
474
- if self._outgoing is None:
475
- await asyncio.sleep(0.1)
476
- continue
477
-
478
- if self._circuit_breaker:
479
- if time.time() - self._last_error_time > 60:
480
- self._circuit_breaker = False
481
- self._error_count = 0
482
- self.logger.info("Circuit breaker reset (socket)")
483
- else:
484
- await asyncio.sleep(5)
485
- continue
486
-
487
- message = await self._outgoing.get() # TODO: persistent msg q mb?
488
-
489
- if not message:
490
- continue
491
-
492
- retry_count = message.get("retry_count", 0)
493
- max_retries = message.get("max_retries", 3)
494
-
495
- try:
496
- await self._send_and_wait(
497
- opcode=message["opcode"],
498
- payload=message["payload"],
499
- cmd=message.get("cmd", 0),
500
- timeout=message.get("timeout", 10.0),
501
- )
502
- self.logger.debug("Message sent successfully from queue (socket)")
503
- self._error_count = max(0, self._error_count - 1)
504
- except Exception as e:
505
- self._error_count += 1
506
- self._last_error_time = time.time()
507
-
508
- if self._error_count > 10: # TODO: export to constant
509
- self._circuit_breaker = True
510
- self.logger.warning(
511
- "Circuit breaker activated due to %d consecutive errors (socket)",
512
- self._error_count,
513
- )
514
- await self._outgoing.put(message)
515
- continue
516
-
517
- retry_delay = self._get_retry_delay(e, retry_count)
518
- self.logger.warning(
519
- "Failed to send message from queue (socket): %s (delay: %ds)",
520
- e,
521
- retry_delay,
522
- )
523
-
524
- if retry_count < max_retries:
525
- message["retry_count"] = retry_count + 1
526
- await asyncio.sleep(retry_delay)
527
- await self._outgoing.put(message)
528
- else:
529
- self.logger.error(
530
- "Message failed after %d retries, dropping (socket)",
531
- max_retries,
532
- )
533
-
534
- except Exception:
535
- self.logger.exception("Error in outgoing loop (socket)")
536
- await asyncio.sleep(1)
537
-
538
- def _get_retry_delay(
539
- self, error: Exception, retry_count: int
540
- ) -> float: # TODO: tune delays later
541
- if isinstance(error, (ConnectionError, OSError, ssl.SSLError)):
542
- return 1.0
543
- elif isinstance(error, TimeoutError):
544
- return 5.0
545
- elif isinstance(error, SocketNotConnectedError):
546
- return 2.0
547
- else:
548
- return 2**retry_count
549
-
550
- async def _queue_message(
551
- self,
552
- opcode: int,
553
- payload: dict[str, Any],
554
- cmd: int = 0,
555
- timeout: float = 10.0,
556
- max_retries: int = 3,
557
- ) -> None:
558
- if self._outgoing is None:
559
- self.logger.warning("Outgoing queue not initialized (socket)")
560
- return
561
-
562
- message = {
563
- "opcode": opcode,
564
- "payload": payload,
565
- "cmd": cmd,
566
- "timeout": timeout,
567
- "retry_count": 0,
568
- "max_retries": max_retries,
569
- }
570
-
571
- await self._outgoing.put(message)
572
- self.logger.debug("Message queued for sending (socket)")
573
-
574
- async def _sync(self) -> None:
575
- self.logger.info("Starting initial sync (socket)")
576
- payload = SyncPayload(
577
- interactive=True,
578
- token=self._token,
579
- chats_sync=0,
580
- contacts_sync=0,
581
- presence_sync=0,
582
- drafts_sync=0,
583
- chats_count=40,
584
- ).model_dump(by_alias=True)
585
- data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload)
586
- raw_payload = data.get("payload", {})
587
- if error := raw_payload.get("error"):
588
- localized_message = raw_payload.get("localizedMessage")
589
- title = raw_payload.get("title")
590
- message = raw_payload.get("message")
591
- raise Error(
592
- error=error,
593
- message=message,
594
- title=title,
595
- localized_message=localized_message,
596
- )
597
- for raw_chat in raw_payload.get("chats", []):
598
- try:
599
- if raw_chat.get("type") == "DIALOG":
600
- self.dialogs.append(Dialog.from_dict(raw_chat))
601
- elif raw_chat.get("type") == "CHAT":
602
- self.chats.append(Chat.from_dict(raw_chat))
603
- elif raw_chat.get("type") == "CHANNEL":
604
- self.channels.append(Channel.from_dict(raw_chat))
605
- except Exception:
606
- self.logger.exception("Error parsing chat entry (socket)")
607
-
608
- for raw_user in raw_payload.get("contacts", []):
609
- try:
610
- user = User.from_dict(raw_user)
611
- if user:
612
- self.contacts.append(user)
613
- except Exception:
614
- self.logger.exception("Error parsing contact entry (socket)")
615
-
616
- if raw_payload.get("profile", {}).get("contact"):
617
- self.me = Me.from_dict(raw_payload.get("profile", {}).get("contact", {}))
618
- self.logger.info(
619
- "Sync completed: dialogs=%d chats=%d channels=%d",
620
- len(self.dialogs),
621
- len(self.chats),
622
- len(self.channels),
623
- )
299
+ self._pending.pop(msg["seq"] % 256, None)
624
300
 
625
301
  @override
626
302
  async def _get_chat(self, chat_id: int) -> Chat | None:
pymax/mixins/telemetry.py CHANGED
@@ -2,21 +2,19 @@ import asyncio
2
2
  import random
3
3
  import time
4
4
 
5
- from pymax.exceptions import Error
6
- from pymax.interfaces import ClientProtocol
5
+ from pymax.exceptions import Error, SocketNotConnectedError
7
6
  from pymax.navigation import Navigation
8
7
  from pymax.payloads import (
9
8
  NavigationEventParams,
10
9
  NavigationEventPayload,
11
10
  NavigationPayload,
12
11
  )
12
+ from pymax.protocols import ClientProtocol
13
13
  from pymax.static.enum import Opcode
14
14
 
15
15
 
16
16
  class TelemetryMixin(ClientProtocol):
17
- async def _send_navigation_event(
18
- self, events: list[NavigationEventPayload]
19
- ) -> None:
17
+ async def _send_navigation_event(self, events: list[NavigationEventPayload]) -> None:
20
18
  try:
21
19
  payload = NavigationPayload(events=events).model_dump(by_alias=True)
22
20
  data = await self._send_and_wait(
@@ -105,7 +103,13 @@ class TelemetryMixin(ClientProtocol):
105
103
 
106
104
  try:
107
105
  while self.is_connected:
108
- await self._send_random_navigation()
106
+ try:
107
+ await self._send_random_navigation()
108
+ except SocketNotConnectedError:
109
+ self.logger.debug("Socket disconnected, exiting telemetry task")
110
+ break
111
+ except Exception:
112
+ self.logger.warning("Failed to send random navigation")
109
113
  await asyncio.sleep(self._get_random_sleep_time())
110
114
 
111
115
  except asyncio.CancelledError:
pymax/mixins/user.py CHANGED
@@ -1,15 +1,15 @@
1
1
  from typing import Any, Literal
2
2
 
3
3
  from pymax.exceptions import Error, ResponseError, ResponseStructureError
4
- from pymax.interfaces import ClientProtocol
5
- from pymax.mixins.utils import MixinsUtils
6
4
  from pymax.payloads import (
7
5
  ContactActionPayload,
8
6
  FetchContactsPayload,
9
7
  SearchByPhonePayload,
10
8
  )
9
+ from pymax.protocols import ClientProtocol
11
10
  from pymax.static.enum import ContactAction, Opcode
12
11
  from pymax.types import Contact, Session, User
12
+ from pymax.utils import MixinsUtils
13
13
 
14
14
 
15
15
  class UserMixin(ClientProtocol):
@@ -122,9 +122,7 @@ class UserMixin(ClientProtocol):
122
122
 
123
123
  payload = SearchByPhonePayload(phone=phone).model_dump(by_alias=True)
124
124
 
125
- data = await self._send_and_wait(
126
- opcode=Opcode.CONTACT_INFO_BY_PHONE, payload=payload
127
- )
125
+ data = await self._send_and_wait(opcode=Opcode.CONTACT_INFO_BY_PHONE, payload=payload)
128
126
 
129
127
  if data.get("payload", {}).get("error"):
130
128
  MixinsUtils.handle_error(data)