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/interfaces.py CHANGED
@@ -1,128 +1,35 @@
1
1
  import asyncio
2
2
  import contextlib
3
+ import json
3
4
  import logging
4
- import socket
5
- import ssl
5
+ import time
6
6
  import traceback
7
- from abc import ABC, abstractmethod
7
+ from abc import abstractmethod
8
8
  from collections.abc import Awaitable, Callable
9
- from logging import Logger
10
- from typing import TYPE_CHECKING, Any, Literal
9
+ from typing import Any
11
10
 
12
11
  from typing_extensions import Self
13
12
 
14
- from pymax.exceptions import WebSocketNotConnectedError
13
+ from pymax.exceptions import SocketNotConnectedError, WebSocketNotConnectedError
14
+ from pymax.filters import BaseFilter
15
15
  from pymax.formatter import ColoredFormatter
16
-
17
- from .payloads import UserAgentPayload
18
- from .static.constant import DEFAULT_TIMEOUT
19
- from .static.enum import Opcode
20
- from .types import Channel, Chat, Dialog, Me, Message, User
21
-
22
- if TYPE_CHECKING:
23
- from pathlib import Path
24
- from uuid import UUID
25
-
26
- import websockets
27
-
28
- from pymax import AttachType
29
- from pymax.types import ReactionInfo
30
-
31
- from .crud import Database
32
- from .filters import BaseFilter
33
-
34
-
35
- class ClientProtocol(ABC):
36
- def __init__(self, logger: Logger) -> None:
37
- super().__init__()
38
- self.logger = logger
39
- self._users: dict[int, User] = {}
40
- self.chats: list[Chat] = []
41
- self._database: Database
42
- self._device_id: UUID
43
- self.uri: str
44
- self.is_connected: bool = False
45
- self.phone: str
46
- self.dialogs: list[Dialog] = []
47
- self.channels: list[Channel] = []
48
- self.contacts: list[User] = []
49
- self.me: Me | None = None
50
- self.host: str
51
- self.port: int
52
- self.proxy: str | Literal[True] | None
53
- self.registration: bool
54
- self.first_name: str
55
- self.last_name: str | None
56
- self._token: str | None
57
- self._work_dir: str
58
- self.reconnect: bool
59
- self._database_path: Path
60
- self._ws: websockets.ClientConnection | None = None
61
- self._seq: int = 0
62
- self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
63
- self._recv_task: asyncio.Task[Any] | None = None
64
- self._incoming: asyncio.Queue[dict[str, Any]] | None = None
65
- self._file_upload_waiters: dict[
66
- int,
67
- asyncio.Future[dict[str, Any]],
68
- ] = {}
69
- self.user_agent = UserAgentPayload()
70
- self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
71
- self._outgoing_task: asyncio.Task[Any] | None = None
72
- self._error_count: int = 0
73
- self._circuit_breaker: bool = False
74
- self._last_error_time: float = 0.0
75
- self._session_id: int
76
- self._action_id: int = 0
77
- self._current_screen: str = "chats_list_tab"
78
- self._on_message_handlers: list[
79
- tuple[Callable[[Message], Any], BaseFilter[Message] | None]
80
- ] = []
81
- self._on_message_edit_handlers: list[
82
- tuple[Callable[[Message], Any], BaseFilter[Message] | None]
83
- ] = []
84
- self._on_message_delete_handlers: list[
85
- tuple[Callable[[Message], Any], BaseFilter[Message] | None]
86
- ] = []
87
- self._on_reaction_change_handlers: list[Callable[[str, int, ReactionInfo], Any]] = []
88
- self._on_chat_update_handlers: list[Callable[[Chat], Any | Awaitable[Any]]] = []
89
- self._on_raw_receive_handlers: list[Callable[[dict[str, Any]], Any | Awaitable[Any]]] = []
90
- self._scheduled_tasks: list[tuple[Callable[[], Any | Awaitable[Any]], float]] = []
91
- self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
92
- self._background_tasks: set[asyncio.Task[Any]] = set()
93
- self._ssl_context: ssl.SSLContext
94
- self._socket: socket.socket | None = None
95
-
96
- @abstractmethod
97
- async def _send_and_wait(
98
- self,
99
- opcode: Opcode,
100
- payload: dict[str, Any],
101
- cmd: int = 0,
102
- timeout: float = DEFAULT_TIMEOUT,
103
- ) -> dict[str, Any]:
104
- pass
105
-
106
- @abstractmethod
107
- async def _get_chat(self, chat_id: int) -> Chat | None:
108
- pass
109
-
110
- @abstractmethod
111
- async def _queue_message(
112
- self,
113
- opcode: int,
114
- payload: dict[str, Any],
115
- cmd: int = 0,
116
- timeout: float = DEFAULT_TIMEOUT,
117
- max_retries: int = 3,
118
- ) -> Message | None:
119
- pass
120
-
121
- @abstractmethod
122
- def _create_safe_task(
123
- self, coro: Awaitable[Any], name: str | None = None
124
- ) -> asyncio.Task[Any]:
125
- pass
16
+ from pymax.payloads import BaseWebSocketMessage, SyncPayload, UserAgentPayload
17
+ from pymax.protocols import ClientProtocol
18
+ from pymax.static.constant import DEFAULT_PING_INTERVAL, DEFAULT_TIMEOUT
19
+ from pymax.static.enum import Opcode
20
+ from pymax.types import (
21
+ Channel,
22
+ Chat,
23
+ ChatType,
24
+ Dialog,
25
+ Me,
26
+ Message,
27
+ MessageStatus,
28
+ ReactionCounter,
29
+ ReactionInfo,
30
+ User,
31
+ )
32
+ from pymax.utils import MixinsUtils
126
33
 
127
34
 
128
35
  class BaseClient(ClientProtocol):
@@ -255,3 +162,397 @@ class BaseClient(ClientProtocol):
255
162
  @abstractmethod
256
163
  async def close(self) -> None:
257
164
  pass
165
+
166
+
167
+ class BaseTransport(ClientProtocol):
168
+ @abstractmethod
169
+ async def connect(
170
+ self, user_agent: UserAgentPayload | None = None
171
+ ) -> dict[str, Any] | None: ...
172
+
173
+ @abstractmethod
174
+ async def _send_and_wait(
175
+ self,
176
+ opcode: Opcode,
177
+ payload: dict[str, Any],
178
+ cmd: int = 0,
179
+ timeout: float = DEFAULT_TIMEOUT,
180
+ ) -> dict[str, Any]: ...
181
+
182
+ @abstractmethod
183
+ async def _recv_loop(self) -> None: ...
184
+
185
+ def _make_message(
186
+ self, opcode: Opcode, payload: dict[str, Any], cmd: int = 0
187
+ ) -> dict[str, Any]:
188
+ self._seq += 1
189
+
190
+ msg = BaseWebSocketMessage(
191
+ ver=11,
192
+ cmd=cmd,
193
+ seq=self._seq,
194
+ opcode=opcode.value,
195
+ payload=payload,
196
+ ).model_dump(by_alias=True)
197
+
198
+ self.logger.debug("make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq)
199
+ return msg
200
+
201
+ async def _send_interactive_ping(self) -> None:
202
+ while self.is_connected:
203
+ try:
204
+ await self._send_and_wait(
205
+ opcode=Opcode.PING,
206
+ payload={"interactive": True},
207
+ cmd=0,
208
+ )
209
+ self.logger.debug("Interactive ping sent successfully")
210
+ except SocketNotConnectedError:
211
+ self.logger.debug("Socket disconnected, exiting ping loop")
212
+ break
213
+ except Exception:
214
+ self.logger.warning("Interactive ping failed")
215
+ await asyncio.sleep(DEFAULT_PING_INTERVAL)
216
+
217
+ async def _handshake(self, user_agent: UserAgentPayload) -> dict[str, Any]:
218
+ self.logger.debug(
219
+ "Sending handshake with user_agent keys=%s",
220
+ user_agent.model_dump(by_alias=True).keys(),
221
+ )
222
+
223
+ user_agent_json = user_agent.model_dump(by_alias=True)
224
+ resp = await self._send_and_wait(
225
+ opcode=Opcode.SESSION_INIT,
226
+ payload={"deviceId": str(self._device_id), "userAgent": user_agent_json},
227
+ )
228
+
229
+ if resp.get("payload", {}).get("error"):
230
+ MixinsUtils.handle_error(resp)
231
+
232
+ self.logger.info("Handshake completed")
233
+ return resp
234
+
235
+ async def _process_message_handler(
236
+ self,
237
+ handler: Callable[[Message], Any],
238
+ filter: BaseFilter[Message] | None,
239
+ message: Message,
240
+ ):
241
+ result = None
242
+ if filter:
243
+ if filter(message):
244
+ result = handler(message)
245
+ else:
246
+ return
247
+ else:
248
+ result = handler(message)
249
+ if asyncio.iscoroutine(result):
250
+ self._create_safe_task(result, name=f"handler-{handler.__name__}")
251
+
252
+ def _parse_json(self, raw: Any) -> dict[str, Any] | None:
253
+ try:
254
+ return json.loads(raw)
255
+ except Exception:
256
+ self.logger.warning("JSON parse error", exc_info=True)
257
+ return None
258
+
259
+ def _handle_pending(self, seq: int | None, data: dict) -> bool:
260
+ if isinstance(seq, int):
261
+ fut = self._pending.get(seq)
262
+ if fut and not fut.done():
263
+ fut.set_result(data)
264
+ self.logger.debug("Matched response for pending seq=%s", seq)
265
+ return True
266
+ return False
267
+
268
+ async def _handle_incoming_queue(self, data: dict[str, Any]) -> None:
269
+ if self._incoming:
270
+ try:
271
+ self._incoming.put_nowait(data)
272
+ except asyncio.QueueFull:
273
+ self.logger.warning(
274
+ "Incoming queue full; dropping message seq=%s", data.get("seq")
275
+ )
276
+
277
+ async def _handle_file_upload(self, data: dict[str, Any]) -> None:
278
+ if data.get("opcode") != Opcode.NOTIF_ATTACH:
279
+ return
280
+ payload = data.get("payload", {})
281
+ for key in ("fileId", "videoId"):
282
+ id_ = payload.get(key)
283
+ if id_ is not None:
284
+ fut = self._file_upload_waiters.pop(id_, None)
285
+ if fut and not fut.done():
286
+ fut.set_result(data)
287
+ self.logger.debug("Fulfilled file upload waiter for %s=%s", key, id_)
288
+
289
+ async def _send_notification_response(self, chat_id: int, message_id: str) -> None:
290
+ if self._socket is not None and self.is_connected:
291
+ return
292
+ await self._send_and_wait(
293
+ opcode=Opcode.NOTIF_MESSAGE,
294
+ payload={"chatId": chat_id, "messageId": message_id},
295
+ cmd=0,
296
+ )
297
+ self.logger.debug(
298
+ "Sent NOTIF_MESSAGE_RECEIVED for chat_id=%s message_id=%s", chat_id, message_id
299
+ )
300
+
301
+ async def _handle_message_notifications(self, data: dict) -> None:
302
+ if data.get("opcode") != Opcode.NOTIF_MESSAGE.value:
303
+ return
304
+ payload = data.get("payload", {})
305
+ msg = Message.from_dict(payload)
306
+ if not msg:
307
+ return
308
+
309
+ if msg.chat_id and msg.id:
310
+ await self._send_notification_response(msg.chat_id, str(msg.id))
311
+
312
+ handlers_map = {
313
+ MessageStatus.EDITED: self._on_message_edit_handlers,
314
+ MessageStatus.REMOVED: self._on_message_delete_handlers,
315
+ }
316
+ if msg.status and msg.status in handlers_map:
317
+ for handler, filter in handlers_map[msg.status]:
318
+ await self._process_message_handler(handler, filter, msg)
319
+ if msg.status is None:
320
+ for handler, filter in self._on_message_handlers:
321
+ await self._process_message_handler(handler, filter, msg)
322
+
323
+ async def _handle_reactions(self, data: dict):
324
+ if data.get("opcode") != Opcode.NOTIF_MSG_REACTIONS_CHANGED:
325
+ return
326
+
327
+ payload = data.get("payload", {})
328
+ chat_id = payload.get("chatId")
329
+ message_id = payload.get("messageId")
330
+
331
+ if not (chat_id and message_id):
332
+ return
333
+
334
+ total_count = payload.get("totalCount")
335
+ your_reaction = payload.get("yourReaction")
336
+ counters = [ReactionCounter.from_dict(c) for c in payload.get("counters", [])]
337
+
338
+ reaction_info = ReactionInfo(
339
+ total_count=total_count,
340
+ your_reaction=your_reaction,
341
+ counters=counters,
342
+ )
343
+
344
+ for handler in self._on_reaction_change_handlers:
345
+ try:
346
+ result = handler(message_id, chat_id, reaction_info)
347
+ if asyncio.iscoroutine(result):
348
+ await result
349
+ except Exception as e:
350
+ self.logger.exception("Error in on_reaction_change_handler: %s", e)
351
+
352
+ async def _handle_chat_updates(self, data: dict) -> None:
353
+ if data.get("opcode") != Opcode.NOTIF_CHAT:
354
+ return
355
+
356
+ payload = data.get("payload", {})
357
+ chat_data = payload.get("chat", {})
358
+ chat = Chat.from_dict(chat_data)
359
+ if not chat:
360
+ return
361
+
362
+ for handler in self._on_chat_update_handlers:
363
+ try:
364
+ result = handler(chat)
365
+ if asyncio.iscoroutine(result):
366
+ await result
367
+ except Exception as e:
368
+ self.logger.exception("Error in on_chat_update_handler: %s", e)
369
+
370
+ async def _handle_raw_receive(self, data: dict[str, Any]) -> None:
371
+ for handler in self._on_raw_receive_handlers:
372
+ try:
373
+ result = handler(data)
374
+ if asyncio.iscoroutine(result):
375
+ await result
376
+ except Exception as e:
377
+ self.logger.exception("Error in on_raw_receive_handler: %s", e)
378
+
379
+ async def _dispatch_incoming(self, data: dict[str, Any]) -> None:
380
+ await self._handle_raw_receive(data)
381
+ await self._handle_file_upload(data)
382
+ await self._handle_message_notifications(data)
383
+ await self._handle_reactions(data)
384
+ await self._handle_chat_updates(data)
385
+
386
+ def _log_task_exception(self, fut: asyncio.Future[Any]) -> None:
387
+ try:
388
+ fut.result()
389
+ except asyncio.CancelledError:
390
+ pass
391
+ except Exception as e:
392
+ self.logger.exception("Error retrieving task exception: %s", e)
393
+
394
+ async def _queue_message(
395
+ self,
396
+ opcode: int,
397
+ payload: dict[str, Any],
398
+ cmd: int = 0,
399
+ timeout: float = DEFAULT_TIMEOUT,
400
+ max_retries: int = 3,
401
+ ) -> None:
402
+ if self._outgoing is None:
403
+ self.logger.warning("Outgoing queue not initialized")
404
+ return
405
+
406
+ message = {
407
+ "opcode": opcode,
408
+ "payload": payload,
409
+ "cmd": cmd,
410
+ "timeout": timeout,
411
+ "retry_count": 0,
412
+ "max_retries": max_retries,
413
+ }
414
+
415
+ await self._outgoing.put(message)
416
+ self.logger.debug("Message queued for sending")
417
+
418
+ async def _outgoing_loop(self) -> None:
419
+ while self.is_connected:
420
+ try:
421
+ if self._outgoing is None:
422
+ await asyncio.sleep(0.1)
423
+ continue
424
+
425
+ if self._circuit_breaker:
426
+ if time.time() - self._last_error_time > 60:
427
+ self._circuit_breaker = False
428
+ self._error_count = 0
429
+ self.logger.info("Circuit breaker reset")
430
+ else:
431
+ await asyncio.sleep(5)
432
+ continue
433
+
434
+ message = await self._outgoing.get() # TODO: persistent msg q mb?
435
+ if not message:
436
+ continue
437
+
438
+ retry_count = message.get("retry_count", 0)
439
+ max_retries = message.get("max_retries", 3)
440
+
441
+ try:
442
+ await self._send_and_wait(
443
+ opcode=message["opcode"],
444
+ payload=message["payload"],
445
+ cmd=message.get("cmd", 0),
446
+ timeout=message.get("timeout", DEFAULT_TIMEOUT),
447
+ )
448
+ self.logger.debug("Message sent successfully from queue")
449
+ self._error_count = max(0, self._error_count - 1)
450
+ except Exception as e:
451
+ self._error_count += 1
452
+ self._last_error_time = time.time()
453
+
454
+ if self._error_count > 10:
455
+ self._circuit_breaker = True
456
+ self.logger.warning(
457
+ "Circuit breaker activated due to %d consecutive errors",
458
+ self._error_count,
459
+ )
460
+ await self._outgoing.put(message)
461
+ continue
462
+
463
+ retry_delay = self._get_retry_delay(e, retry_count)
464
+ self.logger.warning(
465
+ "Failed to send message from queue: %s (delay: %ds)",
466
+ e,
467
+ retry_delay,
468
+ )
469
+
470
+ if retry_count < max_retries:
471
+ message["retry_count"] = retry_count + 1
472
+ await asyncio.sleep(retry_delay)
473
+ await self._outgoing.put(message)
474
+ else:
475
+ self.logger.error(
476
+ "Message failed after %d retries, dropping",
477
+ max_retries,
478
+ )
479
+
480
+ except Exception:
481
+ self.logger.exception("Error in outgoing loop")
482
+ await asyncio.sleep(1)
483
+
484
+ def _get_retry_delay(self, error: Exception, retry_count: int) -> float:
485
+ if isinstance(error, (ConnectionError, OSError)):
486
+ return 1.0
487
+ elif isinstance(error, TimeoutError):
488
+ return 5.0
489
+ elif isinstance(error, WebSocketNotConnectedError):
490
+ return 2.0
491
+ else:
492
+ return float(2**retry_count)
493
+
494
+ async def _sync(self, user_agent: UserAgentPayload | None = None) -> None:
495
+ self.logger.info("Starting initial sync")
496
+
497
+ if user_agent is None:
498
+ user_agent = self.headers or UserAgentPayload()
499
+
500
+ payload = SyncPayload(
501
+ interactive=True,
502
+ token=self._token,
503
+ chats_sync=0,
504
+ contacts_sync=0,
505
+ presence_sync=0,
506
+ drafts_sync=0,
507
+ chats_count=40,
508
+ user_agent=user_agent,
509
+ ).model_dump(by_alias=True)
510
+ try:
511
+ data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload)
512
+ raw_payload = data.get("payload", {})
513
+
514
+ if error := raw_payload.get("error"):
515
+ MixinsUtils.handle_error(data)
516
+
517
+ for raw_chat in raw_payload.get("chats", []):
518
+ try:
519
+ if raw_chat.get("type") == ChatType.DIALOG.value:
520
+ self.dialogs.append(Dialog.from_dict(raw_chat))
521
+ elif raw_chat.get("type") == ChatType.CHAT.value:
522
+ self.chats.append(Chat.from_dict(raw_chat))
523
+ elif raw_chat.get("type") == ChatType.CHANNEL.value:
524
+ self.channels.append(Channel.from_dict(raw_chat))
525
+ except Exception:
526
+ self.logger.exception("Error parsing chat entry")
527
+
528
+ for raw_user in raw_payload.get("contacts", []):
529
+ try:
530
+ user = User.from_dict(raw_user)
531
+ if user:
532
+ self.contacts.append(user)
533
+ except Exception:
534
+ self.logger.exception("Error parsing contact entry")
535
+
536
+ if raw_payload.get("profile", {}).get("contact"):
537
+ self.me = Me.from_dict(raw_payload.get("profile", {}).get("contact", {}))
538
+
539
+ self.logger.info(
540
+ "Sync completed: dialogs=%d chats=%d channels=%d",
541
+ len(self.dialogs),
542
+ len(self.chats),
543
+ len(self.channels),
544
+ )
545
+
546
+ except Exception as e:
547
+ self.logger.exception("Sync failed")
548
+ self.is_connected = False
549
+ if self._ws:
550
+ await self._ws.close()
551
+ self._ws = None
552
+ raise
553
+
554
+ async def _get_chat(self, chat_id: int) -> Chat | None:
555
+ for chat in self.chats:
556
+ if chat.id == chat_id:
557
+ return chat
558
+ return None