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