maxapi-python 1.1.20__py3-none-any.whl → 1.2.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.
pymax/mixins/socket.py CHANGED
@@ -11,7 +11,7 @@ import msgpack
11
11
  from typing_extensions import override
12
12
 
13
13
  from pymax.exceptions import Error, SocketNotConnectedError, SocketSendError
14
- from pymax.filters import Filter
14
+ from pymax.filters import BaseFilter
15
15
  from pymax.interfaces import ClientProtocol
16
16
  from pymax.payloads import BaseWebSocketMessage, SyncPayload, UserAgentPayload
17
17
  from pymax.static.constant import (
@@ -92,7 +92,17 @@ class SocketMixin(ClientProtocol):
92
92
  payload_len_b = payload_len.to_bytes(4, "big")
93
93
  return ver_b + cmd_b + seq_b + opcode_b + payload_len_b + payload_bytes
94
94
 
95
- async def connect(self, user_agent: UserAgentPayload) -> dict[str, Any]:
95
+ async def connect(self, user_agent: UserAgentPayload | None = None) -> dict[str, Any]:
96
+ """
97
+ Устанавливает соединение с сервером и выполняет handshake.
98
+
99
+ :param user_agent: Пользовательский агент для handshake. Если None, используется значение по умолчанию.
100
+ :type user_agent: UserAgentPayload | None
101
+ :return: Результат handshake.
102
+ :rtype: dict[str, Any] | None
103
+ """
104
+ if user_agent is None:
105
+ user_agent = UserAgentPayload()
96
106
  if sys.version_info[:2] == (3, 12):
97
107
  self.logger.warning(
98
108
  """
@@ -107,9 +117,7 @@ Socket connections may be unstable, SSL issues are possible.
107
117
  raw_sock = await loop.run_in_executor(
108
118
  None, lambda: socket.create_connection((self.host, self.port))
109
119
  )
110
- self._socket = self._ssl_context.wrap_socket(
111
- raw_sock, server_hostname=self.host
112
- )
120
+ self._socket = self._ssl_context.wrap_socket(raw_sock, server_hostname=self.host)
113
121
  self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
114
122
  self.is_connected = True
115
123
  self._incoming = asyncio.Queue()
@@ -139,6 +147,182 @@ Socket connections may be unstable, SSL issues are possible.
139
147
  self.logger.error("Handshake failed: %s", e, exc_info=True)
140
148
  raise ConnectionError(f"Handshake failed: {e}")
141
149
 
150
+ def _recv_exactly(self, sock: socket.socket, n: int) -> bytes:
151
+ buf = bytearray()
152
+ while len(buf) < n:
153
+ chunk = sock.recv(n - len(buf))
154
+ if not chunk:
155
+ return bytes(buf)
156
+ buf.extend(chunk)
157
+ return bytes(buf)
158
+
159
+ async def _parse_header(
160
+ self, loop: asyncio.AbstractEventLoop, sock: socket.socket
161
+ ) -> bytes | None:
162
+ header = await loop.run_in_executor(None, lambda: self._recv_exactly(sock=sock, n=10))
163
+ if not header or len(header) < 10:
164
+ self.logger.info("Socket connection closed; exiting recv loop")
165
+ self.is_connected = False
166
+ try:
167
+ sock.close()
168
+ except Exception:
169
+ return None
170
+
171
+ return header
172
+
173
+ async def _recv_data(
174
+ self, loop: asyncio.AbstractEventLoop, header: bytes, sock: socket.socket
175
+ ) -> list[dict[str, Any]] | None:
176
+ packed_len = int.from_bytes(header[6:10], "big", signed=False)
177
+ payload_length = packed_len & 0xFFFFFF
178
+ remaining = payload_length
179
+ payload = bytearray()
180
+
181
+ while remaining > 0:
182
+ min_read = min(remaining, 8192)
183
+ chunk = await loop.run_in_executor(None, lambda: self._recv_exactly(sock, min_read))
184
+ if not chunk:
185
+ self.logger.error("Connection closed while reading payload")
186
+ break
187
+ payload.extend(chunk)
188
+ remaining -= len(chunk)
189
+
190
+ if remaining > 0:
191
+ self.logger.error("Incomplete payload received; skipping packet")
192
+ return None
193
+
194
+ raw = header + payload
195
+ if len(raw) < 10 + payload_length:
196
+ self.logger.error(
197
+ "Incomplete packet: expected %d bytes, got %d",
198
+ 10 + payload_length,
199
+ len(raw),
200
+ )
201
+ await asyncio.sleep(RECV_LOOP_BACKOFF_DELAY)
202
+ return None
203
+
204
+ data = self._unpack_packet(raw)
205
+ if not data:
206
+ self.logger.warning("Failed to unpack packet, skipping")
207
+ return None
208
+
209
+ payload_objs = data.get("payload")
210
+ return (
211
+ [{**data, "payload": obj} for obj in payload_objs]
212
+ if isinstance(payload_objs, list)
213
+ else [data]
214
+ )
215
+
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
+
142
326
  async def _recv_loop(self) -> None:
143
327
  if self._socket is None:
144
328
  self.logger.warning("Recv loop started without socket instance")
@@ -147,237 +331,35 @@ Socket connections may be unstable, SSL issues are possible.
147
331
  sock = self._socket
148
332
  loop = asyncio.get_running_loop()
149
333
 
150
- def _recv_exactly(n: int) -> bytes:
151
- """Синхронная функция: читает ровно n байт из сокета или возвращает b'' если закрыт."""
152
- buf = bytearray()
153
- while len(buf) < n:
154
- chunk = sock.recv(n - len(buf))
155
- if not chunk:
156
- return bytes(buf)
157
- buf.extend(chunk)
158
- return bytes(buf)
334
+ while True:
335
+ try:
336
+ header = await self._parse_header(loop, sock)
159
337
 
160
- try:
161
- while True:
162
- try:
163
- header = await loop.run_in_executor(None, lambda: _recv_exactly(10))
164
- if not header or len(header) < 10:
165
- self.logger.info("Socket connection closed; exiting recv loop")
166
- self.is_connected = False
167
- try:
168
- sock.close()
169
- except Exception:
170
- pass # nosec B110
171
- finally:
172
- break
173
-
174
- packed_len = int.from_bytes(header[6:10], "big", signed=False)
175
- payload_length = packed_len & 0xFFFFFF
176
- remaining = payload_length
177
- payload = bytearray()
178
-
179
- while remaining > 0:
180
- min_read = min(remaining, 8192)
181
- chunk = await loop.run_in_executor(
182
- None, _recv_exactly, min_read
183
- )
184
- if not chunk:
185
- self.logger.error("Connection closed while reading payload")
186
- break
187
- payload.extend(chunk)
188
- remaining -= len(chunk)
338
+ if not header:
339
+ break
189
340
 
190
- if remaining > 0:
191
- self.logger.error(
192
- "Incomplete payload received; skipping packet"
193
- )
194
- continue
341
+ datas = await self._recv_data(loop, header, sock)
195
342
 
196
- raw = header + payload
197
- if len(raw) < 10 + payload_length:
198
- self.logger.error(
199
- "Incomplete packet: expected %d bytes, got %d",
200
- 10 + payload_length,
201
- len(raw),
202
- )
203
- await asyncio.sleep(RECV_LOOP_BACKOFF_DELAY)
204
- continue
343
+ if not datas:
344
+ continue
345
+
346
+ for data_item in datas:
347
+ seq = data_item.get("seq")
205
348
 
206
- data = self._unpack_packet(raw)
207
- if not data:
208
- self.logger.warning("Failed to unpack packet, skipping")
349
+ if self._handle_pending(seq, data_item):
209
350
  continue
210
351
 
211
- payload_objs = data.get("payload")
212
- datas = (
213
- [{**data, "payload": obj} for obj in payload_objs]
214
- if isinstance(payload_objs, list)
215
- else [data]
216
- )
352
+ if self._incoming is not None:
353
+ await self._handle_incoming_queue(data_item)
217
354
 
218
- for data_item in datas:
219
- seq = data_item.get("seq")
220
- fut = self._pending.get(seq) if isinstance(seq, int) else None
221
- if fut and not fut.done():
222
- fut.set_result(data_item)
223
- self.logger.debug(
224
- "Matched response for pending seq=%s", seq
225
- )
226
- continue
227
-
228
- if self._incoming is not None:
229
- try:
230
- self._incoming.put_nowait(data_item)
231
- except asyncio.QueueFull:
232
- self.logger.warning(
233
- "Incoming queue full; dropping message seq=%s",
234
- seq,
235
- )
236
-
237
- if (
238
- data_item.get("opcode") == Opcode.NOTIF_MESSAGE
239
- and self._on_message_handlers
240
- ):
241
- try:
242
- for (
243
- handler,
244
- filter,
245
- ) in self._on_message_handlers:
246
- payload = data_item.get("payload", {})
247
- msg_dict = (
248
- payload if isinstance(payload, dict) else None
249
- )
250
- msg = (
251
- Message.from_dict(msg_dict)
252
- if msg_dict
253
- else None
254
- )
255
- if msg and msg.status:
256
- if msg.status == MessageStatus.EDITED:
257
- for (
258
- edit_handler,
259
- edit_filter,
260
- ) in self._on_message_edit_handlers:
261
- await self._process_message_handler(
262
- handler=edit_handler,
263
- filter=edit_filter,
264
- message=msg,
265
- )
266
- elif msg.status == MessageStatus.REMOVED:
267
- for (
268
- remove_handler,
269
- remove_filter,
270
- ) in self._on_message_delete_handlers:
271
- await self._process_message_handler(
272
- handler=remove_handler,
273
- filter=remove_filter,
274
- message=msg,
275
- )
276
- await self._process_message_handler(
277
- handler=handler,
278
- filter=filter,
279
- message=msg,
280
- )
281
- except Exception:
282
- self.logger.exception("Error in on_message_handler")
283
-
284
- if (
285
- data_item.get("opcode")
286
- == Opcode.NOTIF_MSG_REACTIONS_CHANGED
287
- ):
288
- try:
289
- for (
290
- reaction_handler,
291
- ) in self._on_reaction_change_handlers:
292
- payload = data_item.get("payload", {})
293
-
294
- chat_id = payload.get("chatId")
295
- message_id = payload.get("messageId")
296
-
297
- total_count = payload.get("totalCount")
298
- your_reaction = payload.get("yourReaction")
299
- counters = [
300
- ReactionCounter.from_dict(c)
301
- for c in payload.get("counters", [])
302
- ]
303
-
304
- if (
305
- chat_id
306
- and message_id
307
- and (
308
- total_count is not None
309
- or your_reaction
310
- or counters
311
- )
312
- ):
313
- reaction_info = ReactionInfo(
314
- total_count=total_count,
315
- your_reaction=your_reaction,
316
- counters=counters,
317
- )
318
- result = reaction_handler(
319
- message_id, chat_id, reaction_info
320
- )
321
- if asyncio.iscoroutine(result):
322
- await result
323
-
324
- except Exception as e:
325
- self.logger.exception(
326
- "Error in on_reaction_change_handler: %s", e
327
- )
328
-
329
- if data_item.get("opcode") == Opcode.NOTIF_CHAT:
330
- try:
331
- for (
332
- chat_update_handler,
333
- ) in self._on_chat_update_handlers:
334
- payload = data_item.get("payload", {})
335
- chat = Chat.from_dict(payload.get("chat", {}))
336
- if chat:
337
- result = chat_update_handler(chat)
338
- if asyncio.iscoroutine(result):
339
- await result
340
- except Exception as e:
341
- self.logger.exception(
342
- "Error in on_chat_update_handler: %s", e
343
- )
344
-
345
- try: # TODO: переделать, временное решение
346
- if data_item.get("opcode") == Opcode.NOTIF_ATTACH:
347
- file_id = data_item.get("payload", {}).get(
348
- "fileId", None
349
- )
350
- video_id = data_item.get("payload", {}).get(
351
- "videoId", None
352
- )
353
- if file_id is not None:
354
- fut = self._file_upload_waiters.pop(file_id, None)
355
- if fut and not fut.done():
356
- fut.set_result(data)
357
- self.logger.debug(
358
- "Fulfilled file upload waiter for fileId=%s",
359
- file_id,
360
- )
361
- elif video_id is not None:
362
- fut = self._file_upload_waiters.pop(video_id, None)
363
- if fut and not fut.done():
364
- fut.set_result(data)
365
- self.logger.debug(
366
- "Fulfilled file upload waiter for videoId=%s",
367
- video_id,
368
- )
369
- except Exception:
370
- self.logger.exception(
371
- "Error handling file upload notification"
372
- )
373
- except asyncio.CancelledError:
374
- self.logger.debug("Recv loop cancelled")
375
- break
376
- except Exception:
377
- self.logger.exception("Error in recv_loop; backing off briefly")
378
- await asyncio.sleep(RECV_LOOP_BACKOFF_DELAY)
379
- finally:
380
- self.logger.warning("<<< Recv loop exited (socket)")
355
+ await self._dispatch_incoming(data_item)
356
+
357
+ except asyncio.CancelledError:
358
+ self.logger.debug("Recv loop cancelled")
359
+ raise
360
+ except Exception:
361
+ self.logger.exception("Error in recv_loop; backing off briefly")
362
+ await asyncio.sleep(RECV_LOOP_BACKOFF_DELAY)
381
363
 
382
364
  def _log_task_exception(self, fut: asyncio.Future[Any]) -> None:
383
365
  try:
@@ -391,10 +373,10 @@ Socket connections may be unstable, SSL issues are possible.
391
373
  async def _process_message_handler(
392
374
  self,
393
375
  handler: Callable[[Message], Any],
394
- filter: Filter | None,
376
+ filter: BaseFilter[Message] | None,
395
377
  message: Message,
396
378
  ) -> None:
397
- if filter and not filter.match(message):
379
+ if filter is not None and not filter(message):
398
380
  return
399
381
 
400
382
  result = handler(message)
@@ -427,9 +409,7 @@ Socket connections may be unstable, SSL issues are possible.
427
409
  opcode=opcode.value,
428
410
  payload=payload,
429
411
  ).model_dump(by_alias=True)
430
- self.logger.debug(
431
- "make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq
432
- )
412
+ self.logger.debug("make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq)
433
413
  return msg
434
414
 
435
415
  @override
@@ -481,9 +461,7 @@ Socket connections may be unstable, SSL issues are possible.
481
461
  raise exc from conn_err
482
462
  raise SocketNotConnectedError from conn_err
483
463
  except Exception as exc:
484
- self.logger.exception(
485
- "Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"]
486
- )
464
+ self.logger.exception("Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"])
487
465
  raise SocketSendError from exc
488
466
 
489
467
  finally:
@@ -593,51 +571,46 @@ Socket connections may be unstable, SSL issues are possible.
593
571
  self.logger.debug("Message queued for sending (socket)")
594
572
 
595
573
  async def _sync(self) -> None:
596
- try:
597
- self.logger.info("Starting initial sync (socket)")
598
- payload = SyncPayload(
599
- interactive=True,
600
- token=self._token,
601
- chats_sync=0,
602
- contacts_sync=0,
603
- presence_sync=0,
604
- drafts_sync=0,
605
- chats_count=40,
606
- ).model_dump(by_alias=True)
607
- data = await self._send_and_wait(opcode=Opcode.LOGIN, payload=payload)
608
- raw_payload = data.get("payload", {})
609
- if error := raw_payload.get("error"):
610
- localized_message = raw_payload.get("localizedMessage")
611
- title = raw_payload.get("title")
612
- message = raw_payload.get("message")
613
- raise Error(
614
- error=error,
615
- message=message,
616
- title=title,
617
- localized_message=localized_message,
618
- )
619
- for raw_chat in raw_payload.get("chats", []):
620
- try:
621
- if raw_chat.get("type") == "DIALOG":
622
- self.dialogs.append(Dialog.from_dict(raw_chat))
623
- elif raw_chat.get("type") == "CHAT":
624
- self.chats.append(Chat.from_dict(raw_chat))
625
- elif raw_chat.get("type") == "CHANNEL":
626
- self.channels.append(Channel.from_dict(raw_chat))
627
- except Exception:
628
- self.logger.exception("Error parsing chat entry (socket)")
629
- if raw_payload.get("profile", {}).get("contact"):
630
- self.me = Me.from_dict(
631
- raw_payload.get("profile", {}).get("contact", {})
632
- )
633
- self.logger.info(
634
- "Sync completed: dialogs=%d chats=%d channels=%d",
635
- len(self.dialogs),
636
- len(self.chats),
637
- len(self.channels),
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,
638
595
  )
639
- except Exception:
640
- self.logger.exception("Sync failed (socket)")
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
+ )
641
614
 
642
615
  @override
643
616
  async def _get_chat(self, chat_id: int) -> Chat | None: