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