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/message.py CHANGED
@@ -1,9 +1,11 @@
1
1
  import asyncio
2
2
  import time
3
3
  from http import HTTPStatus
4
+ from pathlib import Path
4
5
 
5
6
  import aiohttp
6
- from aiohttp import ClientSession
7
+ from aiofiles import open as aio_open
8
+ from aiohttp import ClientSession, TCPConnector
7
9
 
8
10
  from pymax.exceptions import Error
9
11
  from pymax.files import File, Photo, Video
@@ -37,15 +39,17 @@ from pymax.types import (
37
39
  FileRequest,
38
40
  Message,
39
41
  ReactionInfo,
40
- VideoAttach,
41
42
  VideoRequest,
42
43
  )
43
44
 
44
45
 
45
46
  class MessageMixin(ClientProtocol):
47
+ CHUNK_SIZE = 6 * 1024 * 1024
48
+
46
49
  async def _upload_file(self, file: File) -> None | Attach:
47
50
  try:
48
51
  self.logger.info("Uploading file")
52
+
49
53
  payload = UploadPayload().model_dump(by_alias=True)
50
54
  data = await self._send_and_wait(
51
55
  opcode=Opcode.FILE_UPLOAD,
@@ -60,46 +64,118 @@ class MessageMixin(ClientProtocol):
60
64
  self.logger.error("No upload URL or file ID received")
61
65
  return None
62
66
 
63
- file_bytes = await file.read()
67
+ self.logger.debug("Got upload URL and file_id=%s", file_id)
68
+
69
+ if file.path:
70
+ file_size = Path(file.path).stat().st_size
71
+ self.logger.info(
72
+ "File size from path: %.2f MB", file_size / (1024 * 1024)
73
+ )
74
+ else:
75
+ file_bytes = await file.read()
76
+ file_size = len(file_bytes)
77
+ self.logger.info(
78
+ "File size from URL: %.2f MB", file_size / (1024 * 1024)
79
+ )
80
+
81
+ connector = TCPConnector(limit=0)
82
+ timeout = aiohttp.ClientTimeout(total=None, sock_read=None, sock_connect=30)
64
83
 
65
84
  headers = {
66
85
  "Content-Disposition": f"attachment; filename={file.file_name}",
67
- "Content-Range": f"0-{len(file_bytes) - 1}/{len(file_bytes)}",
86
+ "Content-Length": str(file_size),
87
+ "Content-Range": f"0-{file_size - 1}/{file_size}",
68
88
  }
69
89
 
70
90
  loop = asyncio.get_running_loop()
71
91
  fut: asyncio.Future[dict] = loop.create_future()
72
- try:
73
- self._file_upload_waiters[int(file_id)] = fut
74
- except Exception:
75
- self.logger.exception("Failed to register file upload waiter")
92
+ self._file_upload_waiters[int(file_id)] = fut
93
+
94
+ async def file_generator():
95
+ bytes_sent = 0
96
+ chunk_num = 0
97
+ self.logger.debug("Starting file streaming from: %s", file.path)
98
+ async with aio_open(file.path, "rb") as f:
99
+ while True:
100
+ chunk = await f.read(self.CHUNK_SIZE)
101
+ if not chunk:
102
+ self.logger.info(
103
+ "File streaming complete: %d bytes in %d chunks",
104
+ bytes_sent,
105
+ chunk_num,
106
+ )
107
+ break
108
+
109
+ yield chunk
110
+
111
+ bytes_sent += len(chunk)
112
+ chunk_num += 1
113
+ if chunk_num % 10 == 0:
114
+ self.logger.info(
115
+ "Upload progress: %.1f MB in %d chunks",
116
+ bytes_sent / (1024 * 1024),
117
+ chunk_num,
118
+ )
119
+ if chunk_num % 4 == 0:
120
+ await asyncio.sleep(0)
121
+
122
+ async def bytes_generator(b: bytes):
123
+ bytes_sent = 0
124
+ chunk_num = 0
125
+ for i in range(0, len(b), self.CHUNK_SIZE):
126
+ chunk = b[i : i + self.CHUNK_SIZE]
127
+ yield chunk
128
+ bytes_sent += len(chunk)
129
+ chunk_num += 1
130
+ if chunk_num % 10 == 0:
131
+ self.logger.info(
132
+ "Upload progress: %.1f MB in %d chunks",
133
+ bytes_sent / (1024 * 1024),
134
+ chunk_num,
135
+ )
136
+ if chunk_num % 4 == 0:
137
+ await asyncio.sleep(0)
138
+
139
+ if file.path:
140
+ data_to_send = file_generator()
141
+ else:
142
+ data_to_send = bytes_generator(file_bytes)
143
+
144
+ self.logger.info("Starting file upload: %s", file.file_name)
76
145
 
77
146
  async with (
78
- ClientSession() as session,
79
- session.post(
80
- url=url,
81
- headers=headers,
82
- data=file_bytes,
83
- ) as response,
147
+ ClientSession(connector=connector, timeout=timeout) as session,
148
+ session.post(url=url, headers=headers, data=data_to_send) as response,
84
149
  ):
150
+ self.logger.debug("Server response status: %d", response.status)
85
151
  if response.status != HTTPStatus.OK:
86
- self.logger.error(f"Upload failed with status {response.status}")
152
+ self.logger.error("Upload failed with status %s", response.status)
87
153
  self._file_upload_waiters.pop(int(file_id), None)
88
154
  return None
89
155
 
156
+ self.logger.debug(
157
+ "File sent successfully. Waiting for server confirmation "
158
+ "(timeout=%d seconds, fileId=%s)",
159
+ DEFAULT_TIMEOUT,
160
+ file_id,
161
+ )
90
162
  try:
91
163
  await asyncio.wait_for(fut, timeout=DEFAULT_TIMEOUT)
164
+ self.logger.info(
165
+ "File upload completed successfully (fileId=%s)", file_id
166
+ )
92
167
  return Attach(_type=AttachType.FILE, file_id=file_id)
93
168
  except asyncio.TimeoutError:
94
- self.logger.error(
169
+ self.logger.warning(
95
170
  "Timed out waiting for file processing notification for fileId=%s",
96
171
  file_id,
97
172
  )
98
173
  self._file_upload_waiters.pop(int(file_id), None)
99
174
  return None
100
- except Exception as e:
101
- self.logger.exception("Upload file failed: %s", str(e))
102
- raise e
175
+
176
+ except Exception:
177
+ self.logger.exception("Upload file failed")
178
+ raise
103
179
 
104
180
  async def _upload_video(self, video: Video) -> None | Attach:
105
181
  try:
@@ -127,10 +203,19 @@ class MessageMixin(ClientProtocol):
127
203
  return None
128
204
 
129
205
  file_bytes = await video.read()
206
+ file_size = len(file_bytes)
207
+
208
+ # Настройки для ClientSession
209
+ connector = TCPConnector(limit=0)
210
+ timeout = aiohttp.ClientTimeout(
211
+ total=900, sock_read=60
212
+ ) # 15 минут на видео
130
213
 
131
214
  headers = {
132
215
  "Content-Disposition": f"attachment; filename={video.file_name}",
133
- "Content-Range": f"0-{len(file_bytes) - 1}/{len(file_bytes)}",
216
+ "Content-Range": f"0-{file_size - 1}/{file_size}",
217
+ "Content-Length": str(file_size),
218
+ "Connection": "keep-alive",
134
219
  }
135
220
 
136
221
  loop = asyncio.get_running_loop()
@@ -140,35 +225,45 @@ class MessageMixin(ClientProtocol):
140
225
  except Exception:
141
226
  self.logger.exception("Failed to register file upload waiter")
142
227
 
143
- async with (
144
- ClientSession() as session,
145
- session.post(
146
- url=url,
147
- headers=headers,
148
- data=file_bytes,
149
- ) as response,
150
- ):
151
- if response.status != HTTPStatus.OK:
152
- self.logger.error(f"Upload failed with status {response.status}")
153
- self._file_upload_waiters.pop(int(video_id), None)
154
- return None
155
-
156
- try:
157
- await asyncio.wait_for(fut, timeout=DEFAULT_TIMEOUT)
158
- return Attach(
159
- _type=AttachType.VIDEO, video_id=video_id, token=token
160
- )
161
- except asyncio.TimeoutError:
162
- self.logger.error(
163
- "Timed out waiting for video processing notification for videoId=%s",
164
- video_id,
228
+ try:
229
+ async with ClientSession(
230
+ connector=connector, timeout=timeout
231
+ ) as session:
232
+ async with session.post(
233
+ url=url,
234
+ headers=headers,
235
+ data=file_bytes,
236
+ ) as response:
237
+ if response.status != HTTPStatus.OK:
238
+ self.logger.error(
239
+ "Upload failed with status %s", response.status
240
+ )
241
+ self._file_upload_waiters.pop(int(video_id), None)
242
+ return None
243
+
244
+ try:
245
+ await asyncio.wait_for(fut, timeout=DEFAULT_TIMEOUT)
246
+ return Attach(
247
+ _type=AttachType.VIDEO, video_id=video_id, token=token
248
+ )
249
+ except asyncio.TimeoutError:
250
+ self.logger.warning(
251
+ "Timed out waiting for video processing notification for videoId=%s",
252
+ video_id,
253
+ )
254
+ self._file_upload_waiters.pop(int(video_id), None)
255
+ return None
256
+ except OSError as e:
257
+ if "malloc failure" in str(e) or "BUF" in str(e):
258
+ self.logger.exception(
259
+ "Memory error during video upload. File too large or insufficient memory. Try uploading smaller files or free up memory."
165
260
  )
166
261
  self._file_upload_waiters.pop(int(video_id), None)
167
- return None
262
+ raise
168
263
 
169
- except Exception as e:
170
- self.logger.exception("Upload video failed: %s", str(e))
171
- raise e
264
+ except Exception:
265
+ self.logger.exception("Upload video failed")
266
+ raise
172
267
 
173
268
  async def _upload_photo(self, photo: Photo) -> None | Attach:
174
269
  try:
@@ -265,7 +360,25 @@ class MessageMixin(ClientProtocol):
265
360
  use_queue: bool = False,
266
361
  ) -> Message | None:
267
362
  """
268
- Отправляет сообщение в чат.
363
+ Отправляет текстовое сообщение в чат с опциональными вложениями.
364
+
365
+ :param text: Текст сообщения.
366
+ :type text: str
367
+ :param chat_id: Идентификатор чата, в который отправляется сообщение.
368
+ :type chat_id: int
369
+ :param notify: Флаг оповещения о новом сообщении. По умолчанию True.
370
+ :type notify: bool
371
+ :param attachment: Одно вложение (фото, файл или видео).
372
+ :type attachment: Photo | File | Video | None
373
+ :param attachments: Список множественных вложений.
374
+ :type attachments: list[Photo | File | Video] | None
375
+ :param reply_to: Идентификатор сообщения для ответа.
376
+ :type reply_to: int | None
377
+ :param use_queue: Использовать очередь для отправки. По умолчанию False.
378
+ :type use_queue: bool
379
+ :return: Объект сообщения или None, если используется очередь.
380
+ :rtype: Message | None
381
+ :raises Error: Если загрузка вложения или отправка сообщения не удалась.
269
382
  """
270
383
 
271
384
  self.logger.info("Sending message to chat_id=%s notify=%s", chat_id, notify)
@@ -301,9 +414,9 @@ class MessageMixin(ClientProtocol):
301
414
 
302
415
  elements = []
303
416
  clean_text = None
304
- raw_elements = Formatting.get_elements_from_markdown(text)[0]
417
+ raw_elements, parsed_text = Formatting.get_elements_from_markdown(text)
305
418
  if raw_elements:
306
- clean_text = Formatting.get_elements_from_markdown(text)[1]
419
+ clean_text = parsed_text
307
420
  elements = [
308
421
  MessageElement(type=e.type, length=e.length, from_=e.from_)
309
422
  for e in raw_elements
@@ -349,6 +462,25 @@ class MessageMixin(ClientProtocol):
349
462
  attachments: list[Photo | Video | File] | None = None,
350
463
  use_queue: bool = False,
351
464
  ) -> Message | None:
465
+ """
466
+ Редактирует текст и/или вложения существующего сообщения.
467
+
468
+ :param chat_id: Идентификатор чата.
469
+ :type chat_id: int
470
+ :param message_id: Идентификатор сообщения для редактирования.
471
+ :type message_id: int
472
+ :param text: Новый текст сообщения.
473
+ :type text: str
474
+ :param attachment: Новое вложение (фото, файл или видео).
475
+ :type attachment: Photo | File | Video | None
476
+ :param attachments: Список новых множественных вложений.
477
+ :type attachments: list[Photo | Video | File] | None
478
+ :param use_queue: Использовать очередь для отправки.
479
+ :type use_queue: bool
480
+ :return: Отредактированное сообщение или None.
481
+ :rtype: Message | None
482
+ :raises Error: Если редактирование не удалось.
483
+ """
352
484
  self.logger.info(
353
485
  "Editing message chat_id=%s message_id=%s", chat_id, message_id
354
486
  )
@@ -428,7 +560,18 @@ class MessageMixin(ClientProtocol):
428
560
  use_queue: bool = False,
429
561
  ) -> bool:
430
562
  """
431
- Удаляет сообщения.
563
+ Удаляет одно или несколько сообщений.
564
+
565
+ :param chat_id: Идентификатор чата.
566
+ :type chat_id: int
567
+ :param message_ids: Список идентификаторов сообщений для удаления.
568
+ :type message_ids: list[int]
569
+ :param for_me: Удалить только для себя (не видимо другим).
570
+ :type for_me: bool
571
+ :param use_queue: Использовать очередь для отправки.
572
+ :type use_queue: bool
573
+ :return: True, если сообщения успешно удалены.
574
+ :rtype: bool
432
575
  """
433
576
  self.logger.info(
434
577
  "Deleting messages chat_id=%s ids=%s for_me=%s",
@@ -458,15 +601,16 @@ class MessageMixin(ClientProtocol):
458
601
  self, chat_id: int, message_id: int, notify_pin: bool
459
602
  ) -> bool:
460
603
  """
461
- Закрепляет сообщение.
462
-
463
- Args:
464
- chat_id (int): ID чата
465
- message_id (int): ID сообщения
466
- notify_pin (bool): Оповещать о закреплении
467
-
468
- Returns:
469
- bool: True, если сообщение закреплено
604
+ Закрепляет сообщение в чате.
605
+
606
+ :param chat_id: Идентификатор чата.
607
+ :type chat_id: int
608
+ :param message_id: Идентификатор сообщения.
609
+ :type message_id: int
610
+ :param notify_pin: Отправить уведомление о закреплении.
611
+ :type notify_pin: bool
612
+ :return: True, если сообщение успешно закреплено.
613
+ :rtype: bool
470
614
  """
471
615
  payload = PinMessagePayload(
472
616
  chat_id=chat_id,
@@ -490,7 +634,18 @@ class MessageMixin(ClientProtocol):
490
634
  backward: int = 200,
491
635
  ) -> list[Message] | None:
492
636
  """
493
- Получает историю сообщений чата.
637
+ Получает историю сообщений из чата.
638
+
639
+ :param chat_id: Идентификатор чата.
640
+ :type chat_id: int
641
+ :param from_time: Временная метка для начала выборки.
642
+ :type from_time: int | None
643
+ :param forward: Кол-во сообщений вперед от from_time.
644
+ :type forward: int
645
+ :param backward: Кол-во сообщений назад от from_time.
646
+ :type backward: int
647
+ :return: Список сообщений или None.
648
+ :rtype: list[Message] | None
494
649
  """
495
650
  if from_time is None:
496
651
  from_time = int(time.time() * 1000)
@@ -534,15 +689,14 @@ class MessageMixin(ClientProtocol):
534
689
  """
535
690
  Получает видео
536
691
 
537
- Args:
538
- chat_id (int): ID чата
539
- message_id (int): ID сообщения
540
- video_id (int): ID видео
541
-
542
- Returns:
543
- external (str): Странная ссылка из апи
544
- cache (bool): True, если видео кэшировано
545
- url (str): Ссылка на видео
692
+ :param chat_id: ID чата
693
+ :type chat_id: int
694
+ :param message_id: ID сообщения
695
+ :type message_id: int
696
+ :param video_id: ID видео
697
+ :type video_id: int
698
+ :return: Объект VideoRequest или None
699
+ :rtype: VideoRequest | None
546
700
  """
547
701
  self.logger.info("Getting video_id=%s message_id=%s", video_id, message_id)
548
702
 
@@ -578,14 +732,14 @@ class MessageMixin(ClientProtocol):
578
732
  """
579
733
  Получает файл
580
734
 
581
- Args:
582
- chat_id (int): ID чата
583
- message_id (int): ID сообщения
584
- file_id (int): ID видео
585
-
586
- Returns:
587
- unsafe (bool): Проверка файла на безопасность максом
588
- url (str): Ссылка на скачивание файла
735
+ :param chat_id: ID чата
736
+ :type chat_id: int
737
+ :param message_id: ID сообщения
738
+ :type message_id: int
739
+ :param file_id: ID файла
740
+ :type file_id: int
741
+ :return: Объект FileRequest или None
742
+ :rtype: FileRequest | None
589
743
  """
590
744
  self.logger.info("Getting file_id=%s message_id=%s", file_id, message_id)
591
745
  if self.is_connected and self._socket is not None:
@@ -620,13 +774,14 @@ class MessageMixin(ClientProtocol):
620
774
  """
621
775
  Добавляет реакцию к сообщению.
622
776
 
623
- Args:
624
- chat_id (int): ID чата
625
- message_id (int): ID сообщения
626
- reaction (str): Реакция (эмодзи)
627
-
628
- Returns:
629
- ReactionInfo | None: Информация о реакции или None при ошибке.
777
+ :param chat_id: ID чата
778
+ :type chat_id: int
779
+ :param message_id: ID сообщения
780
+ :type message_id: str
781
+ :param reaction: Реакция для добавления
782
+ :type reaction: str (emoji)
783
+ :return: Объект ReactionInfo или None
784
+ :rtype: ReactionInfo | None
630
785
  """
631
786
  try:
632
787
  self.logger.info(
@@ -665,13 +820,12 @@ class MessageMixin(ClientProtocol):
665
820
  """
666
821
  Получает реакции на сообщения.
667
822
 
668
- Args:
669
- chat_id (int): ID чата
670
- message_ids (list[str]): Список ID сообщений
671
-
672
- Returns:
673
- dict[str, ReactionInfo] | None: Словарь с ID сообщений и информацией
674
- о реакциях или None при ошибке.
823
+ :param chat_id: ID чата
824
+ :type chat_id: int
825
+ :param message_ids: Список ID сообщений
826
+ :type message_ids: list[str]
827
+ :return: Словарь с ID сообщений и соответствующими реакциями
828
+ :rtype: dict[str, ReactionInfo] | None
675
829
  """
676
830
  self.logger.info(
677
831
  "Getting reactions for messages chat_id=%s message_ids=%s",
@@ -707,12 +861,12 @@ class MessageMixin(ClientProtocol):
707
861
  """
708
862
  Удаляет реакцию с сообщения.
709
863
 
710
- Args:
711
- chat_id (int): ID чата
712
- message_id (str): ID сообщения
713
-
714
- Returns:
715
- ReactionInfo | None: Информация о реакции или None при ошибке.
864
+ :param chat_id: ID чата
865
+ :type chat_id: int
866
+ :param message_id: ID сообщения
867
+ :type message_id: str
868
+ :return: Объект ReactionInfo или None
869
+ :rtype: ReactionInfo | None
716
870
  """
717
871
  self.logger.info(
718
872
  "Removing reaction from message chat_id=%s message_id=%s",
@@ -0,0 +1,28 @@
1
+ import asyncio
2
+ import traceback
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import Any
5
+
6
+ from pymax.interfaces import ClientProtocol
7
+
8
+
9
+ class SchedulerMixin(ClientProtocol):
10
+ async def _run_periodic(
11
+ self, func: Callable[[], Any | Awaitable[Any]], interval: float
12
+ ) -> None:
13
+ while True:
14
+ try:
15
+ result = func()
16
+ if asyncio.iscoroutine(result):
17
+ await result
18
+ except Exception as e:
19
+ tb = traceback.format_exc()
20
+ self.logger.error(f"Error in scheduled task {func}: {e}")
21
+ raise
22
+ await asyncio.sleep(interval)
23
+
24
+ async def _start_scheduled_tasks(self) -> None:
25
+ for func, interval in self._scheduled_tasks:
26
+ task = asyncio.create_task(self._run_periodic(func, interval))
27
+ self._background_tasks.add(task)
28
+ task.add_done_callback(self._background_tasks.discard)