maxapi-python 0.1.3__py3-none-any.whl → 1.1.1__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-0.1.3.dist-info → maxapi_python-1.1.1.dist-info}/METADATA +1 -1
- maxapi_python-1.1.1.dist-info/RECORD +28 -0
- {maxapi_python-0.1.3.dist-info → maxapi_python-1.1.1.dist-info}/licenses/LICENSE +21 -21
- pymax/__init__.py +57 -55
- pymax/core.py +193 -156
- pymax/crud.py +99 -99
- pymax/exceptions.py +29 -20
- pymax/files.py +86 -86
- pymax/filters.py +38 -38
- pymax/interfaces.py +79 -67
- pymax/mixins/__init__.py +35 -18
- pymax/mixins/auth.py +81 -81
- pymax/mixins/channel.py +25 -25
- pymax/mixins/group.py +220 -220
- pymax/mixins/handler.py +60 -60
- pymax/mixins/message.py +293 -293
- pymax/mixins/self.py +38 -38
- pymax/mixins/socket.py +380 -0
- pymax/mixins/telemetry.py +114 -0
- pymax/mixins/user.py +82 -82
- pymax/mixins/websocket.py +262 -243
- pymax/models.py +8 -8
- pymax/navigation.py +185 -0
- pymax/payloads.py +195 -175
- pymax/static.py +212 -210
- pymax/types.py +570 -434
- pymax/utils.py +38 -38
- maxapi_python-0.1.3.dist-info/RECORD +0 -25
- {maxapi_python-0.1.3.dist-info → maxapi_python-1.1.1.dist-info}/WHEEL +0 -0
pymax/mixins/socket.py
ADDED
@@ -0,0 +1,380 @@
|
|
1
|
+
import asyncio
|
2
|
+
import socket
|
3
|
+
import ssl
|
4
|
+
import sys
|
5
|
+
from typing import Any
|
6
|
+
|
7
|
+
import lz4.block
|
8
|
+
import msgpack
|
9
|
+
from typing_extensions import override
|
10
|
+
|
11
|
+
from pymax.filters import Message
|
12
|
+
from pymax.interfaces import ClientProtocol
|
13
|
+
from pymax.payloads import BaseWebSocketMessage, SyncPayload
|
14
|
+
from pymax.static import Opcode
|
15
|
+
from pymax.types import Channel, Chat, Dialog, Me
|
16
|
+
|
17
|
+
|
18
|
+
class SocketMixin(ClientProtocol):
|
19
|
+
@property
|
20
|
+
def sock(self) -> socket.socket:
|
21
|
+
if self._socket is None or not self.is_connected:
|
22
|
+
self.logger.critical("Socket not connected when access attempted")
|
23
|
+
raise ConnectionError("Socket not connected")
|
24
|
+
return self._socket
|
25
|
+
|
26
|
+
def _unpack_packet(self, data: bytes) -> dict[str, Any] | None:
|
27
|
+
ver = int.from_bytes(data[0:1], "big")
|
28
|
+
cmd = int.from_bytes(data[1:3], "big")
|
29
|
+
seq = int.from_bytes(data[3:4], "big")
|
30
|
+
opcode = int.from_bytes(data[4:6], "big")
|
31
|
+
packed_len = int.from_bytes(data[6:10], "big", signed=False)
|
32
|
+
comp_flag = packed_len >> 24
|
33
|
+
payload_length = packed_len & 0xFFFFFF
|
34
|
+
payload_bytes = data[10 : 10 + payload_length]
|
35
|
+
|
36
|
+
payload = None
|
37
|
+
if payload_bytes:
|
38
|
+
if comp_flag != 0:
|
39
|
+
uncompressed_size = int.from_bytes(payload_bytes[0:4], "big")
|
40
|
+
compressed_data = payload_bytes
|
41
|
+
try:
|
42
|
+
payload_bytes = lz4.block.decompress(
|
43
|
+
compressed_data,
|
44
|
+
uncompressed_size=99999,
|
45
|
+
)
|
46
|
+
except lz4.block.LZ4BlockError:
|
47
|
+
return None
|
48
|
+
payload = msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False)
|
49
|
+
|
50
|
+
return {
|
51
|
+
"ver": ver,
|
52
|
+
"cmd": cmd,
|
53
|
+
"seq": seq,
|
54
|
+
"opcode": opcode,
|
55
|
+
"payload": payload,
|
56
|
+
}
|
57
|
+
|
58
|
+
def _pack_packet(
|
59
|
+
self, ver: int, cmd: int, seq: int, opcode: int, payload: dict[str, Any]
|
60
|
+
) -> bytes:
|
61
|
+
ver_b = ver.to_bytes(1, "big")
|
62
|
+
cmd_b = cmd.to_bytes(2, "big")
|
63
|
+
seq_b = seq.to_bytes(1, "big")
|
64
|
+
opcode_b = opcode.to_bytes(2, "big")
|
65
|
+
payload_bytes = msgpack.packb(payload)
|
66
|
+
payload_len = len(payload_bytes) & 0xFFFFFF
|
67
|
+
self.logger.debug("Packing message: payload size=%d bytes", len(payload_bytes))
|
68
|
+
payload_len_b = payload_len.to_bytes(4, "big")
|
69
|
+
return ver_b + cmd_b + seq_b + opcode_b + payload_len_b + payload_bytes
|
70
|
+
|
71
|
+
async def _connect(self, user_agent: dict[str, Any]) -> dict[str, Any]:
|
72
|
+
try:
|
73
|
+
if sys.version_info[:2] == (3, 12):
|
74
|
+
self.logger.warning(
|
75
|
+
"""
|
76
|
+
===============================================================
|
77
|
+
⚠️⚠️ \033[0;31mWARNING: Python 3.12 detected!\033[0m ⚠️⚠️
|
78
|
+
Socket connections may be unstable, SSL issues are possible.
|
79
|
+
===============================================================
|
80
|
+
"""
|
81
|
+
)
|
82
|
+
self.logger.info("Connecting to socket %s:%s", self.host, self.port)
|
83
|
+
loop = asyncio.get_running_loop()
|
84
|
+
raw_sock = await loop.run_in_executor(
|
85
|
+
None, lambda: socket.create_connection((self.host, self.port))
|
86
|
+
)
|
87
|
+
self._socket = self._ssl_context.wrap_socket(
|
88
|
+
raw_sock, server_hostname=self.host
|
89
|
+
)
|
90
|
+
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
91
|
+
self.is_connected = True
|
92
|
+
self._incoming = asyncio.Queue()
|
93
|
+
self._pending = {}
|
94
|
+
self._recv_task = asyncio.create_task(self._recv_loop())
|
95
|
+
self.logger.info("Socket connected, starting handshake")
|
96
|
+
return await self._handshake(user_agent)
|
97
|
+
except Exception as e:
|
98
|
+
self.logger.error("Failed to connect: %s", e, exc_info=True)
|
99
|
+
raise ConnectionError(f"Failed to connect: {e}")
|
100
|
+
|
101
|
+
async def _handshake(self, user_agent: dict[str, Any]) -> dict[str, Any]:
|
102
|
+
try:
|
103
|
+
self.logger.debug(
|
104
|
+
"Sending handshake with user_agent keys=%s", list(user_agent.keys())
|
105
|
+
)
|
106
|
+
resp = await self._send_and_wait(
|
107
|
+
opcode=Opcode.SESSION_INIT,
|
108
|
+
payload={"deviceId": str(self._device_id), "userAgent": user_agent},
|
109
|
+
)
|
110
|
+
self.logger.info("Handshake completed")
|
111
|
+
return resp
|
112
|
+
except Exception as e:
|
113
|
+
self.logger.error("Handshake failed: %s", e, exc_info=True)
|
114
|
+
raise ConnectionError(f"Handshake failed: {e}")
|
115
|
+
|
116
|
+
async def _recv_loop(self) -> None:
|
117
|
+
if self._socket is None:
|
118
|
+
self.logger.warning("Recv loop started without socket instance")
|
119
|
+
return
|
120
|
+
|
121
|
+
loop = asyncio.get_running_loop()
|
122
|
+
|
123
|
+
def _recv_exactly(n: int) -> bytes:
|
124
|
+
"""Синхронная функция: читает ровно n байт из сокета или возвращает b'' если закрыт."""
|
125
|
+
buf = bytearray()
|
126
|
+
sock = self._socket
|
127
|
+
while len(buf) < n:
|
128
|
+
chunk = sock.recv(n - len(buf))
|
129
|
+
if not chunk:
|
130
|
+
return bytes(buf)
|
131
|
+
buf.extend(chunk)
|
132
|
+
return bytes(buf)
|
133
|
+
|
134
|
+
try:
|
135
|
+
while True:
|
136
|
+
try:
|
137
|
+
header = await loop.run_in_executor(None, lambda: _recv_exactly(10))
|
138
|
+
if not header or len(header) < 10:
|
139
|
+
self.logger.info("Socket connection closed; exiting recv loop")
|
140
|
+
self.is_connected = False
|
141
|
+
try:
|
142
|
+
self._socket.close()
|
143
|
+
except Exception:
|
144
|
+
pass
|
145
|
+
break
|
146
|
+
|
147
|
+
packed_len = int.from_bytes(header[6:10], "big", signed=False)
|
148
|
+
payload_length = packed_len & 0xFFFFFF
|
149
|
+
remaining = payload_length
|
150
|
+
payload = bytearray()
|
151
|
+
|
152
|
+
while remaining > 0:
|
153
|
+
chunk = await loop.run_in_executor(
|
154
|
+
None, lambda r=remaining: _recv_exactly(min(r, 8192))
|
155
|
+
)
|
156
|
+
if not chunk:
|
157
|
+
self.logger.error("Connection closed while reading payload")
|
158
|
+
break
|
159
|
+
payload.extend(chunk)
|
160
|
+
remaining -= len(chunk)
|
161
|
+
|
162
|
+
if remaining > 0:
|
163
|
+
self.logger.error(
|
164
|
+
"Incomplete payload received; skipping packet"
|
165
|
+
)
|
166
|
+
continue
|
167
|
+
|
168
|
+
raw = header + payload
|
169
|
+
if len(raw) < 10 + payload_length:
|
170
|
+
self.logger.error(
|
171
|
+
"Incomplete packet: expected %d bytes, got %d",
|
172
|
+
10 + payload_length,
|
173
|
+
len(raw),
|
174
|
+
)
|
175
|
+
await asyncio.sleep(0.5)
|
176
|
+
continue
|
177
|
+
|
178
|
+
data = self._unpack_packet(raw)
|
179
|
+
if not data:
|
180
|
+
self.logger.warning("Failed to unpack packet, skipping")
|
181
|
+
continue
|
182
|
+
|
183
|
+
payload_objs = data.get("payload")
|
184
|
+
datas = (
|
185
|
+
[{**data, "payload": obj} for obj in payload_objs]
|
186
|
+
if isinstance(payload_objs, list)
|
187
|
+
else [data]
|
188
|
+
)
|
189
|
+
|
190
|
+
for data_item in datas:
|
191
|
+
seq = data_item.get("seq")
|
192
|
+
fut = self._pending.get(seq) if isinstance(seq, int) else None
|
193
|
+
if fut and not fut.done():
|
194
|
+
fut.set_result(data_item)
|
195
|
+
self.logger.debug(
|
196
|
+
"Matched response for pending seq=%s", seq
|
197
|
+
)
|
198
|
+
continue
|
199
|
+
|
200
|
+
if self._incoming is not None:
|
201
|
+
try:
|
202
|
+
self._incoming.put_nowait(data_item)
|
203
|
+
except asyncio.QueueFull:
|
204
|
+
self.logger.warning(
|
205
|
+
"Incoming queue full; dropping message seq=%s",
|
206
|
+
seq,
|
207
|
+
)
|
208
|
+
|
209
|
+
if (
|
210
|
+
data_item.get("opcode") == Opcode.NOTIF_MESSAGE
|
211
|
+
and self._on_message_handlers
|
212
|
+
):
|
213
|
+
try:
|
214
|
+
for handler, filter in self._on_message_handlers:
|
215
|
+
payload = data_item.get("payload", {})
|
216
|
+
msg_dict = (
|
217
|
+
payload.get("message")
|
218
|
+
if isinstance(payload, dict)
|
219
|
+
else None
|
220
|
+
)
|
221
|
+
msg = (
|
222
|
+
Message.from_dict(msg_dict)
|
223
|
+
if msg_dict
|
224
|
+
else None
|
225
|
+
)
|
226
|
+
if msg and not msg.status:
|
227
|
+
if filter and not filter.match(msg):
|
228
|
+
continue
|
229
|
+
result = handler(msg)
|
230
|
+
if asyncio.iscoroutine(result):
|
231
|
+
task = asyncio.create_task(result)
|
232
|
+
self._background_tasks.add(task)
|
233
|
+
task.add_done_callback(
|
234
|
+
lambda t: self._background_tasks.discard(
|
235
|
+
t
|
236
|
+
)
|
237
|
+
or self._log_task_exception(t)
|
238
|
+
)
|
239
|
+
except Exception:
|
240
|
+
self.logger.exception("Error in on_message_handler")
|
241
|
+
except asyncio.CancelledError:
|
242
|
+
self.logger.debug("Recv loop cancelled")
|
243
|
+
break
|
244
|
+
except Exception:
|
245
|
+
self.logger.exception("Error in recv_loop; backing off briefly")
|
246
|
+
await asyncio.sleep(0.5)
|
247
|
+
finally:
|
248
|
+
self.logger.warning("<<< Recv loop exited (socket)")
|
249
|
+
|
250
|
+
def _log_task_exception(self, task: asyncio.Task[Any]) -> None:
|
251
|
+
try:
|
252
|
+
exc = task.exception()
|
253
|
+
if exc:
|
254
|
+
self.logger.exception("Background task exception: %s", exc)
|
255
|
+
except Exception:
|
256
|
+
pass
|
257
|
+
|
258
|
+
async def _send_interactive_ping(self) -> None:
|
259
|
+
while self.is_connected:
|
260
|
+
try:
|
261
|
+
await self._send_and_wait(
|
262
|
+
opcode=Opcode.PING,
|
263
|
+
payload={"interactive": True},
|
264
|
+
cmd=0,
|
265
|
+
)
|
266
|
+
self.logger.debug("Interactive ping sent successfully (socket)")
|
267
|
+
except Exception:
|
268
|
+
self.logger.warning("Interactive ping failed (socket)", exc_info=True)
|
269
|
+
await asyncio.sleep(30)
|
270
|
+
|
271
|
+
def _make_message(
|
272
|
+
self, opcode: int, payload: dict[str, Any], cmd: int = 0
|
273
|
+
) -> dict[str, Any]:
|
274
|
+
self._seq += 1
|
275
|
+
msg = BaseWebSocketMessage(
|
276
|
+
ver=11,
|
277
|
+
cmd=cmd,
|
278
|
+
seq=self._seq,
|
279
|
+
opcode=opcode,
|
280
|
+
payload=payload,
|
281
|
+
).model_dump(by_alias=True)
|
282
|
+
self.logger.debug(
|
283
|
+
"make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq
|
284
|
+
)
|
285
|
+
return msg
|
286
|
+
|
287
|
+
@override
|
288
|
+
async def _send_and_wait(
|
289
|
+
self,
|
290
|
+
opcode: int,
|
291
|
+
payload: dict[str, Any],
|
292
|
+
cmd: int = 0,
|
293
|
+
timeout: float = 10.0,
|
294
|
+
) -> dict[str, Any]:
|
295
|
+
if not self.is_connected or self._socket is None:
|
296
|
+
raise ConnectionError("Socket not connected")
|
297
|
+
sock = self.sock
|
298
|
+
msg = self._make_message(opcode, payload, cmd)
|
299
|
+
loop = asyncio.get_running_loop()
|
300
|
+
fut: asyncio.Future[dict[str, Any]] = loop.create_future()
|
301
|
+
self._pending[msg["seq"]] = fut
|
302
|
+
try:
|
303
|
+
self.logger.debug(
|
304
|
+
"Sending frame opcode=%s cmd=%s seq=%s", opcode, cmd, msg["seq"]
|
305
|
+
)
|
306
|
+
packet = self._pack_packet(
|
307
|
+
msg["ver"], msg["cmd"], msg["seq"], msg["opcode"], msg["payload"]
|
308
|
+
)
|
309
|
+
await loop.run_in_executor(None, lambda: sock.sendall(packet))
|
310
|
+
data = await asyncio.wait_for(fut, timeout=timeout)
|
311
|
+
self.logger.debug(
|
312
|
+
"Received frame for seq=%s opcode=%s",
|
313
|
+
data.get("seq"),
|
314
|
+
data.get("opcode"),
|
315
|
+
)
|
316
|
+
return data
|
317
|
+
|
318
|
+
except (ssl.SSLEOFError, ssl.SSLError, ConnectionError):
|
319
|
+
self.logger.warning("Connection lost, reconnecting...")
|
320
|
+
self.is_connected = False
|
321
|
+
try:
|
322
|
+
await self._connect(self.user_agent)
|
323
|
+
except Exception:
|
324
|
+
self.logger.error("Reconnect failed", exc_info=True)
|
325
|
+
raise
|
326
|
+
except Exception:
|
327
|
+
self.logger.exception(
|
328
|
+
"Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"]
|
329
|
+
)
|
330
|
+
raise RuntimeError("Send and wait failed (socket)")
|
331
|
+
|
332
|
+
finally:
|
333
|
+
self._pending.pop(msg["seq"], None)
|
334
|
+
|
335
|
+
async def _sync(self) -> None:
|
336
|
+
try:
|
337
|
+
self.logger.info("Starting initial sync (socket)")
|
338
|
+
payload = SyncPayload(
|
339
|
+
interactive=True,
|
340
|
+
token=self._token,
|
341
|
+
chats_sync=0,
|
342
|
+
contacts_sync=0,
|
343
|
+
presence_sync=0,
|
344
|
+
drafts_sync=0,
|
345
|
+
chats_count=40,
|
346
|
+
).model_dump(by_alias=True)
|
347
|
+
data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload)
|
348
|
+
raw_payload = data.get("payload", {})
|
349
|
+
if error := raw_payload.get("error"):
|
350
|
+
self.logger.error("Sync error: %s", error)
|
351
|
+
return
|
352
|
+
for raw_chat in raw_payload.get("chats", []):
|
353
|
+
try:
|
354
|
+
if raw_chat.get("type") == "DIALOG":
|
355
|
+
self.dialogs.append(Dialog.from_dict(raw_chat))
|
356
|
+
elif raw_chat.get("type") == "CHAT":
|
357
|
+
self.chats.append(Chat.from_dict(raw_chat))
|
358
|
+
elif raw_chat.get("type") == "CHANNEL":
|
359
|
+
self.channels.append(Channel.from_dict(raw_chat))
|
360
|
+
except Exception:
|
361
|
+
self.logger.exception("Error parsing chat entry (socket)")
|
362
|
+
if raw_payload.get("profile", {}).get("contact"):
|
363
|
+
self.me = Me.from_dict(
|
364
|
+
raw_payload.get("profile", {}).get("contact", {})
|
365
|
+
)
|
366
|
+
self.logger.info(
|
367
|
+
"Sync completed: dialogs=%d chats=%d channels=%d",
|
368
|
+
len(self.dialogs),
|
369
|
+
len(self.chats),
|
370
|
+
len(self.channels),
|
371
|
+
)
|
372
|
+
except Exception:
|
373
|
+
self.logger.exception("Sync failed (socket)")
|
374
|
+
|
375
|
+
@override
|
376
|
+
async def _get_chat(self, chat_id: int) -> Chat | None:
|
377
|
+
for chat in self.chats:
|
378
|
+
if chat.id == chat_id:
|
379
|
+
return chat
|
380
|
+
return None
|
@@ -0,0 +1,114 @@
|
|
1
|
+
import asyncio
|
2
|
+
import json
|
3
|
+
import random
|
4
|
+
import time
|
5
|
+
|
6
|
+
from pymax.interfaces import ClientProtocol
|
7
|
+
from pymax.navigation import Navigation
|
8
|
+
from pymax.payloads import (
|
9
|
+
NavigationEventParams,
|
10
|
+
NavigationEventPayload,
|
11
|
+
NavigationPayload,
|
12
|
+
)
|
13
|
+
from pymax.static import Opcode
|
14
|
+
|
15
|
+
|
16
|
+
class TelemetryMixin(ClientProtocol):
|
17
|
+
async def _send_navigation_event(
|
18
|
+
self, events: list[NavigationEventPayload]
|
19
|
+
) -> None:
|
20
|
+
try:
|
21
|
+
payload = NavigationPayload(events=events).model_dump(by_alias=True)
|
22
|
+
data = await self._send_and_wait(
|
23
|
+
opcode=Opcode.LOG,
|
24
|
+
payload=payload,
|
25
|
+
)
|
26
|
+
|
27
|
+
payload_data = data.get("payload")
|
28
|
+
|
29
|
+
if payload_data and (error := payload_data.get("error")):
|
30
|
+
self.logger.error("Failed to send navigation event: %s", error)
|
31
|
+
return
|
32
|
+
except Exception:
|
33
|
+
self.logger.warning("Failed to send navigation event", exc_info=True)
|
34
|
+
return
|
35
|
+
|
36
|
+
async def _send_cold_start(self) -> None:
|
37
|
+
if not self.me:
|
38
|
+
self.logger.error("Cannot send cold start, user not set")
|
39
|
+
return
|
40
|
+
|
41
|
+
payload = NavigationEventPayload(
|
42
|
+
event="COLD_START",
|
43
|
+
time=int(time.time() * 1000),
|
44
|
+
user_id=self.me.id,
|
45
|
+
params=NavigationEventParams(
|
46
|
+
action_id=self._action_id,
|
47
|
+
screen_to=Navigation.get_screen_id("chats_list_tab"),
|
48
|
+
screen_from=1,
|
49
|
+
source_id=1,
|
50
|
+
session_id=self._session_id,
|
51
|
+
),
|
52
|
+
)
|
53
|
+
|
54
|
+
self._action_id += 1
|
55
|
+
|
56
|
+
await self._send_navigation_event([payload])
|
57
|
+
|
58
|
+
async def _send_random_navigation(self) -> None:
|
59
|
+
if not self.me:
|
60
|
+
self.logger.error("Cannot send navigation event, user not set")
|
61
|
+
return
|
62
|
+
|
63
|
+
screen_from = self._current_screen
|
64
|
+
screen_to = Navigation.get_random_navigation(screen_from)
|
65
|
+
|
66
|
+
self._action_id += 1
|
67
|
+
self._current_screen = screen_to
|
68
|
+
|
69
|
+
payload = NavigationEventPayload(
|
70
|
+
event="NAV",
|
71
|
+
time=int(time.time() * 1000),
|
72
|
+
user_id=self.me.id,
|
73
|
+
params=NavigationEventParams(
|
74
|
+
action_id=self._action_id,
|
75
|
+
screen_from=Navigation.get_screen_id(screen_from),
|
76
|
+
screen_to=Navigation.get_screen_id(screen_to),
|
77
|
+
source_id=1,
|
78
|
+
session_id=self._session_id,
|
79
|
+
),
|
80
|
+
)
|
81
|
+
|
82
|
+
await self._send_navigation_event([payload])
|
83
|
+
|
84
|
+
def _get_random_sleep_time(self) -> int:
|
85
|
+
# TODO: вынести в статик
|
86
|
+
sleep_options = [
|
87
|
+
(1000, 3000),
|
88
|
+
(300, 1000),
|
89
|
+
(60, 300),
|
90
|
+
(5, 60),
|
91
|
+
(5, 20),
|
92
|
+
]
|
93
|
+
|
94
|
+
weights = [0.05, 0.10, 0.15, 0.20, 0.50]
|
95
|
+
|
96
|
+
low, high = random.choices(sleep_options, weights=weights, k=1)[0]
|
97
|
+
return random.randint(low, high)
|
98
|
+
|
99
|
+
async def _start(self) -> None:
|
100
|
+
if not self.is_connected:
|
101
|
+
self.logger.error("Cannot start telemetry, client not connected")
|
102
|
+
return
|
103
|
+
|
104
|
+
await self._send_cold_start()
|
105
|
+
|
106
|
+
try:
|
107
|
+
while self.is_connected:
|
108
|
+
await self._send_random_navigation()
|
109
|
+
await asyncio.sleep(self._get_random_sleep_time())
|
110
|
+
|
111
|
+
except asyncio.CancelledError:
|
112
|
+
self.logger.debug("Telemetry task cancelled")
|
113
|
+
except Exception:
|
114
|
+
self.logger.warning("Telemetry task failed", exc_info=True)
|
pymax/mixins/user.py
CHANGED
@@ -1,82 +1,82 @@
|
|
1
|
-
from pymax.interfaces import ClientProtocol
|
2
|
-
from pymax.payloads import FetchContactsPayload
|
3
|
-
from pymax.static import Opcode
|
4
|
-
from pymax.types import User
|
5
|
-
|
6
|
-
|
7
|
-
class UserMixin(ClientProtocol):
|
8
|
-
def get_cached_user(self, user_id: int) -> User | None:
|
9
|
-
"""
|
10
|
-
Получает юзера из кеша по его ID
|
11
|
-
|
12
|
-
Args:
|
13
|
-
user_id (int): ID пользователя.
|
14
|
-
|
15
|
-
Returns:
|
16
|
-
User | None: Объект User или None при ошибке.
|
17
|
-
"""
|
18
|
-
user = self._users.get(user_id)
|
19
|
-
self.logger.debug("get_cached_user id=%s hit=%s", user_id, bool(user))
|
20
|
-
return user
|
21
|
-
|
22
|
-
async def get_users(self, user_ids: list[int]) -> list[User]:
|
23
|
-
"""
|
24
|
-
Получает информацию о пользователях по их ID (с кешем).
|
25
|
-
"""
|
26
|
-
self.logger.debug("get_users ids=%s", user_ids)
|
27
|
-
cached = {uid: self._users[uid] for uid in user_ids if uid in self._users}
|
28
|
-
missing_ids = [uid for uid in user_ids if uid not in self._users]
|
29
|
-
|
30
|
-
if missing_ids:
|
31
|
-
self.logger.debug("Fetching missing users: %s", missing_ids)
|
32
|
-
fetched_users = await self.fetch_users(missing_ids)
|
33
|
-
if fetched_users:
|
34
|
-
for user in fetched_users:
|
35
|
-
self._users[user.id] = user
|
36
|
-
cached[user.id] = user
|
37
|
-
|
38
|
-
ordered = [cached[uid] for uid in user_ids if uid in cached]
|
39
|
-
self.logger.debug("get_users result_count=%d", len(ordered))
|
40
|
-
return ordered
|
41
|
-
|
42
|
-
async def get_user(self, user_id: int) -> User | None:
|
43
|
-
"""
|
44
|
-
Получает информацию о пользователе по его ID (с кешем).
|
45
|
-
"""
|
46
|
-
self.logger.debug("get_user id=%s", user_id)
|
47
|
-
if user_id in self._users:
|
48
|
-
return self._users[user_id]
|
49
|
-
|
50
|
-
users = await self.fetch_users([user_id])
|
51
|
-
if users:
|
52
|
-
self._users[user_id] = users[0]
|
53
|
-
return users[0]
|
54
|
-
return None
|
55
|
-
|
56
|
-
async def fetch_users(self, user_ids: list[int]) -> None | list[User]:
|
57
|
-
"""
|
58
|
-
Получает информацию о пользователях по их ID.
|
59
|
-
"""
|
60
|
-
try:
|
61
|
-
self.logger.info("Fetching users count=%d", len(user_ids))
|
62
|
-
|
63
|
-
payload = FetchContactsPayload(contact_ids=user_ids).model_dump(
|
64
|
-
by_alias=True
|
65
|
-
)
|
66
|
-
|
67
|
-
data = await self._send_and_wait(
|
68
|
-
opcode=Opcode.CONTACT_INFO, payload=payload
|
69
|
-
)
|
70
|
-
if error := data.get("payload", {}).get("error"):
|
71
|
-
self.logger.error("Fetch users error: %s", error)
|
72
|
-
return None
|
73
|
-
|
74
|
-
users = [User.from_dict(u) for u in data["payload"].get("contacts", [])]
|
75
|
-
for user in users:
|
76
|
-
self._users[user.id] = user
|
77
|
-
|
78
|
-
self.logger.debug("Fetched users: %d", len(users))
|
79
|
-
return users
|
80
|
-
except Exception:
|
81
|
-
self.logger.exception("Fetch users failed")
|
82
|
-
return []
|
1
|
+
from pymax.interfaces import ClientProtocol
|
2
|
+
from pymax.payloads import FetchContactsPayload
|
3
|
+
from pymax.static import Opcode
|
4
|
+
from pymax.types import User
|
5
|
+
|
6
|
+
|
7
|
+
class UserMixin(ClientProtocol):
|
8
|
+
def get_cached_user(self, user_id: int) -> User | None:
|
9
|
+
"""
|
10
|
+
Получает юзера из кеша по его ID
|
11
|
+
|
12
|
+
Args:
|
13
|
+
user_id (int): ID пользователя.
|
14
|
+
|
15
|
+
Returns:
|
16
|
+
User | None: Объект User или None при ошибке.
|
17
|
+
"""
|
18
|
+
user = self._users.get(user_id)
|
19
|
+
self.logger.debug("get_cached_user id=%s hit=%s", user_id, bool(user))
|
20
|
+
return user
|
21
|
+
|
22
|
+
async def get_users(self, user_ids: list[int]) -> list[User]:
|
23
|
+
"""
|
24
|
+
Получает информацию о пользователях по их ID (с кешем).
|
25
|
+
"""
|
26
|
+
self.logger.debug("get_users ids=%s", user_ids)
|
27
|
+
cached = {uid: self._users[uid] for uid in user_ids if uid in self._users}
|
28
|
+
missing_ids = [uid for uid in user_ids if uid not in self._users]
|
29
|
+
|
30
|
+
if missing_ids:
|
31
|
+
self.logger.debug("Fetching missing users: %s", missing_ids)
|
32
|
+
fetched_users = await self.fetch_users(missing_ids)
|
33
|
+
if fetched_users:
|
34
|
+
for user in fetched_users:
|
35
|
+
self._users[user.id] = user
|
36
|
+
cached[user.id] = user
|
37
|
+
|
38
|
+
ordered = [cached[uid] for uid in user_ids if uid in cached]
|
39
|
+
self.logger.debug("get_users result_count=%d", len(ordered))
|
40
|
+
return ordered
|
41
|
+
|
42
|
+
async def get_user(self, user_id: int) -> User | None:
|
43
|
+
"""
|
44
|
+
Получает информацию о пользователе по его ID (с кешем).
|
45
|
+
"""
|
46
|
+
self.logger.debug("get_user id=%s", user_id)
|
47
|
+
if user_id in self._users:
|
48
|
+
return self._users[user_id]
|
49
|
+
|
50
|
+
users = await self.fetch_users([user_id])
|
51
|
+
if users:
|
52
|
+
self._users[user_id] = users[0]
|
53
|
+
return users[0]
|
54
|
+
return None
|
55
|
+
|
56
|
+
async def fetch_users(self, user_ids: list[int]) -> None | list[User]:
|
57
|
+
"""
|
58
|
+
Получает информацию о пользователях по их ID.
|
59
|
+
"""
|
60
|
+
try:
|
61
|
+
self.logger.info("Fetching users count=%d", len(user_ids))
|
62
|
+
|
63
|
+
payload = FetchContactsPayload(contact_ids=user_ids).model_dump(
|
64
|
+
by_alias=True
|
65
|
+
)
|
66
|
+
|
67
|
+
data = await self._send_and_wait(
|
68
|
+
opcode=Opcode.CONTACT_INFO, payload=payload
|
69
|
+
)
|
70
|
+
if error := data.get("payload", {}).get("error"):
|
71
|
+
self.logger.error("Fetch users error: %s", error)
|
72
|
+
return None
|
73
|
+
|
74
|
+
users = [User.from_dict(u) for u in data["payload"].get("contacts", [])]
|
75
|
+
for user in users:
|
76
|
+
self._users[user.id] = user
|
77
|
+
|
78
|
+
self.logger.debug("Fetched users: %d", len(users))
|
79
|
+
return users
|
80
|
+
except Exception:
|
81
|
+
self.logger.exception("Fetch users failed")
|
82
|
+
return []
|