maxapi-python 1.1.15__py3-none-any.whl → 1.1.16__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxapi-python
3
- Version: 1.1.15
3
+ Version: 1.1.16
4
4
  Summary: Python wrapper для API мессенджера Max
5
5
  Project-URL: Homepage, https://github.com/ink-developer/PyMax
6
6
  Project-URL: Repository, https://github.com/ink-developer/PyMax
@@ -155,10 +155,14 @@ if __name__ == "__main__":
155
155
 
156
156
  [Telegram](https://t.me/pymax_news)
157
157
 
158
- ## Авторы
158
+ ## Star History
159
+
160
+ [![Star History Chart](https://api.star-history.com/svg?repos=ink-developer/PyMax&type=date&legend=top-left)](https://www.star-history.com/#ink-developer/PyMax&type=date&legend=top-left)
159
161
 
160
- - **[ink-developer](https://github.com/ink-developer)** — Оригинальный автор проекта
162
+ ## Авторы
161
163
  - **[ink](https://github.com/ink-developer)** — Главный разработчик, исследование API и его документация
164
+ - **[noxzion](https://github.com/noxzion)** — Оригинальный автор проекта
165
+
162
166
 
163
167
  ## Контрибьюторы
164
168
 
@@ -0,0 +1,31 @@
1
+ pymax/__init__.py,sha256=vsdE56xHZnvDCXqLwSJL3v9MxsXcmmxE9p0QSHG66HA,1846
2
+ pymax/core.py,sha256=EKgMlOnqvsPJwOQeCgQE1tnKk4E8kMnmxCAfo4jrP_k,11083
3
+ pymax/crud.py,sha256=wmJh8MPi3L_HbYp7MJP0eXfDcnjgfkLDa9rHAmXtkow,3219
4
+ pymax/exceptions.py,sha256=nDUNx7bM-Yjugj-qfIllcrnwLg9JpZroYqfXapjYbMQ,3178
5
+ pymax/files.py,sha256=dRuOpvoJZWiH4xa_HVGyqQ-_Zzj-sVikElHmrPjwgs0,3166
6
+ pymax/filters.py,sha256=4hehzyQlyxBxLiEhtSH_KQdFHx8gYaqyIOOD72EsSS0,1536
7
+ pymax/formatter.py,sha256=OVsTwambHhXlOMd0wVECJWuB_S2wSEVKdMLCzgPcvYQ,859
8
+ pymax/formatting.py,sha256=hhtmakfcQDzQRsAckPunnJOCKY2lFdLeIp8Yw53yY8s,2522
9
+ pymax/interfaces.py,sha256=NspCm8asUhG_LHo-CTseGMg-zKAHU5H4bl6BMEwqAK0,3416
10
+ pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
11
+ pymax/navigation.py,sha256=4ia6RGY2pXMArboNhHkbWlWX7LtcYK1VGVXorPX0Pb4,5747
12
+ pymax/payloads.py,sha256=qaafULDGBXsQ7gNFC374wZVUwN5tzJLHwkxtAmglOzU,6292
13
+ pymax/types.py,sha256=tiSU_YpvOlx710SYQbYLNw_SjwefgqOu9Xk5ev_PbFU,30931
14
+ pymax/mixins/__init__.py,sha256=xvjcq-lFVHCPss_t8xxXya0OJnsh-owlBqtUlrXSCcw,695
15
+ pymax/mixins/auth.py,sha256=H4Zp3n8cwpv4Q3Mn1_Kb7Oh9DbTL7T9GcWJ6R1JN7ls,6672
16
+ pymax/mixins/channel.py,sha256=dMuJRnbqZisN8kcPFCCe1sIOOBQl2uT4P49PpZXcoKE,5206
17
+ pymax/mixins/group.py,sha256=7oa7RpiqnlcnAsnIHOfSiujNYAzUZ9lkTy9NGW5KVUE,8654
18
+ pymax/mixins/handler.py,sha256=AhxIRvwftkuWN435_CXede2ZVWrDde4zkMPZtwIm5IU,3892
19
+ pymax/mixins/message.py,sha256=ezU9d6r4MkYjH67gZ9SFLYPKqo4Nb6lswqDsEW5p-Bg,22329
20
+ pymax/mixins/self.py,sha256=tDQrUdUpsCu7qGkWLtKxTfTHPHU5_r3qsn-eptHG2KY,1198
21
+ pymax/mixins/socket.py,sha256=j6XTo_M3rNw-az2PfSW6oJ_YHg9M7cWARY4cXpMllDY,22256
22
+ pymax/mixins/telemetry.py,sha256=LWr68DNQkPhAjGRDYQ5lORXxC3Yw6M9E8sF0TCNISTE,3609
23
+ pymax/mixins/user.py,sha256=5utoK7Z-7lySOg0PEO69b6h_3kjfcnV-YydTZIdNj8g,7120
24
+ pymax/mixins/utils.py,sha256=s3FUf3i_wjn2Gbg5YY1rWZB-90ZEGrrcUuND_MqqSTE,853
25
+ pymax/mixins/websocket.py,sha256=gqGP-3XPrbo4DPqUL4H8tuOAjZQ4QKbBvJGxOFqwk9E,17254
26
+ pymax/static/constant.py,sha256=Q1NrmaRj17Gdhk3FmUp3HIwrad1TDorq3wFdQlOCzN8,1027
27
+ pymax/static/enum.py,sha256=ofqxOsRzi6XvZN_UOPinxug1uPEulJsQ95MWifAfCqA,4562
28
+ maxapi_python-1.1.16.dist-info/METADATA,sha256=OLNhs5zuW9ux6C_57MVhTscaZknfVXh90OYf5P2_X8A,6245
29
+ maxapi_python-1.1.16.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
30
+ maxapi_python-1.1.16.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
31
+ maxapi_python-1.1.16.dist-info/RECORD,,
pymax/__init__.py CHANGED
@@ -9,14 +9,22 @@ from .core import (
9
9
  from .exceptions import (
10
10
  InvalidPhoneError,
11
11
  LoginError,
12
+ ResponseError,
13
+ ResponseStructureError,
14
+ SocketNotConnectedError,
15
+ SocketSendError,
12
16
  WebSocketNotConnectedError,
13
17
  )
14
18
  from .static.enum import (
15
19
  AccessType,
20
+ AttachType,
16
21
  AuthType,
17
22
  ChatType,
23
+ ContactAction,
18
24
  DeviceType,
19
25
  ElementType,
26
+ FormattingType,
27
+ MarkupType,
20
28
  MessageStatus,
21
29
  MessageType,
22
30
  Opcode,
@@ -24,10 +32,26 @@ from .static.enum import (
24
32
  from .types import (
25
33
  Channel,
26
34
  Chat,
35
+ Contact,
36
+ ControlAttach,
27
37
  Dialog,
28
38
  Element,
39
+ FileAttach,
40
+ FileRequest,
41
+ Me,
42
+ Member,
29
43
  Message,
44
+ MessageLink,
45
+ Name,
46
+ Names,
47
+ PhotoAttach,
48
+ Presence,
49
+ ReactionCounter,
50
+ ReactionInfo,
51
+ Session,
30
52
  User,
53
+ VideoAttach,
54
+ VideoRequest,
31
55
  )
32
56
 
33
57
  __author__ = "ink-developer"
@@ -35,19 +59,43 @@ __author__ = "ink-developer"
35
59
  __all__ = [
36
60
  # Перечисления и константы
37
61
  "AccessType",
62
+ "AttachType",
38
63
  "AuthType",
64
+ "ContactAction",
65
+ "FormattingType",
66
+ "MarkupType",
39
67
  # Типы данных
40
68
  "Channel",
41
69
  "Chat",
42
70
  "ChatType",
71
+ "Contact",
72
+ "ControlAttach",
43
73
  "DeviceType",
44
74
  "Dialog",
45
75
  "Element",
46
76
  "ElementType",
77
+ "FileAttach",
78
+ "FileRequest",
79
+ "Me",
80
+ "Member",
81
+ "MessageLink",
82
+ "Name",
83
+ "Names",
84
+ "PhotoAttach",
85
+ "Presence",
86
+ "ReactionCounter",
87
+ "ReactionInfo",
88
+ "Session",
89
+ "VideoAttach",
90
+ "VideoRequest",
47
91
  # Исключения
48
92
  "InvalidPhoneError",
49
93
  "LoginError",
50
94
  "WebSocketNotConnectedError",
95
+ "ResponseError",
96
+ "ResponseStructureError",
97
+ "SocketNotConnectedError",
98
+ "SocketSendError",
51
99
  # Клиент
52
100
  "MaxClient",
53
101
  "Message",
pymax/core.py CHANGED
@@ -3,13 +3,16 @@ import logging
3
3
  import socket
4
4
  import ssl
5
5
  import time
6
+ import traceback
7
+ from collections.abc import Awaitable
6
8
  from pathlib import Path
7
- from typing import Literal
9
+ from typing import TYPE_CHECKING, Any, Literal
8
10
 
9
11
  from typing_extensions import override
10
12
 
11
13
  from .crud import Database
12
14
  from .exceptions import InvalidPhoneError
15
+ from .formatter import ColoredFormatter
13
16
  from .mixins import ApiMixin, SocketMixin, WebSocketMixin
14
17
  from .payloads import UserAgentPayload
15
18
  from .static.constant import (
@@ -18,6 +21,16 @@ from .static.constant import (
18
21
  WEBSOCKET_URI,
19
22
  )
20
23
 
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Awaitable, Callable
26
+ from typing import Any
27
+
28
+ import websockets
29
+
30
+ from .filters import Filter
31
+ from .types import Channel, Chat, Dialog, Me, Message, User
32
+
33
+
21
34
  logger = logging.getLogger(__name__)
22
35
 
23
36
 
@@ -64,9 +77,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
64
77
  last_name: str | None = None,
65
78
  logger: logging.Logger | None = None,
66
79
  ) -> None:
67
- logger = logger or logging.getLogger(f"{__name__}.MaxClient")
68
- ApiMixin.__init__(self, token=token, logger=logger)
69
- WebSocketMixin.__init__(self, token=token, logger=logger)
80
+ self.logger = logger or logging.getLogger(f"{__name__}")
70
81
  self.uri: str = uri
71
82
  self.phone: str = phone
72
83
  if not self._check_phone():
@@ -77,23 +88,55 @@ class MaxClient(ApiMixin, WebSocketMixin):
77
88
  self.first_name: str = first_name
78
89
  self.last_name: str | None = last_name
79
90
  self.proxy: str | Literal[True] | None = proxy
91
+
92
+ self.is_connected: bool = False
93
+
94
+ self.chats: list[Chat] = []
95
+ self.dialogs: list[Dialog] = []
96
+ self.channels: list[Channel] = []
97
+ self.me: Me | None = None
98
+ self._users: dict[int, User] = {}
99
+
80
100
  self._work_dir: str = work_dir
81
101
  self._database_path: Path = Path(work_dir) / "session.db"
82
102
  self._database_path.parent.mkdir(parents=True, exist_ok=True)
83
103
  self._database_path.touch(exist_ok=True)
84
104
  self._database = Database(self._work_dir)
105
+
106
+ self._incoming: asyncio.Queue[dict[str, Any]] | None = None
85
107
  self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
108
+ self._recv_task: asyncio.Task[Any] | None = None
86
109
  self._outgoing_task: asyncio.Task[Any] | None = None
110
+ self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
111
+ self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
112
+ self._background_tasks: set[asyncio.Task[Any]] = set()
113
+
114
+ self._seq: int = 0
87
115
  self._error_count: int = 0
88
116
  self._circuit_breaker: bool = False
89
117
  self._last_error_time: float = 0.0
118
+
90
119
  self._device_id = self._database.get_device_id()
91
120
  self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
121
+
92
122
  self._token = self._database.get_auth_token() or token
93
123
  self.user_agent = headers
94
124
  self._send_fake_telemetry: bool = send_fake_telemetry
95
125
  self._session_id: int = int(time.time() * 1000)
96
126
  self._action_id: int = 1
127
+ self._current_screen: str = "chats_list_tab"
128
+
129
+ self._on_message_handlers: list[
130
+ tuple[Callable[[Message], Any], Filter | None]
131
+ ] = []
132
+ self._on_message_edit_handlers: list[
133
+ tuple[Callable[[Message], Any], Filter | None]
134
+ ] = []
135
+ self._on_message_delete_handlers: list[
136
+ tuple[Callable[[Message], Any], Filter | None]
137
+ ] = []
138
+ self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
139
+
97
140
  self._ssl_context = ssl.create_default_context()
98
141
  self._ssl_context.set_ciphers("DEFAULT")
99
142
  self._ssl_context.check_hostname = True
@@ -101,6 +144,8 @@ class MaxClient(ApiMixin, WebSocketMixin):
101
144
  self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
102
145
  self._ssl_context.load_default_certs()
103
146
  self._socket: socket.socket | None = None
147
+ self._ws: websockets.ClientConnection | None = None
148
+
104
149
  self._setup_logger()
105
150
  self.logger.debug(
106
151
  "Initialized MaxClient uri=%s work_dir=%s",
@@ -109,13 +154,16 @@ class MaxClient(ApiMixin, WebSocketMixin):
109
154
  )
110
155
 
111
156
  def _setup_logger(self) -> None:
112
- if not logger.handlers:
157
+ if not self.logger.handlers:
158
+ if not self.logger.level:
159
+ self.logger.setLevel(logging.INFO)
113
160
  handler = logging.StreamHandler()
114
- formatter = logging.Formatter(
115
- "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
161
+ formatter = ColoredFormatter(
162
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
163
+ datefmt="%Y-%m-%d %H:%M:%S",
116
164
  )
117
165
  handler.setFormatter(formatter)
118
- logger.addHandler(handler)
166
+ self.logger.addHandler(handler)
119
167
 
120
168
  async def _wait_forever(self):
121
169
  try:
@@ -123,6 +171,18 @@ class MaxClient(ApiMixin, WebSocketMixin):
123
171
  except asyncio.CancelledError:
124
172
  self.logger.debug("wait_closed cancelled")
125
173
 
174
+ async def _safe_execute(self, coro, *, context: str = "unknown"):
175
+ """
176
+ Безопасно выполняет пользовательскую корутину.
177
+ Логирует traceback, но не роняет event loop.
178
+ """
179
+ try:
180
+ return await coro
181
+ except Exception as e:
182
+ self.logger.error(
183
+ f"Unhandled exception in {context}: {e}\n{traceback.format_exc()}"
184
+ )
185
+
126
186
  async def close(self) -> None:
127
187
  try:
128
188
  self.logger.info("Closing client")
@@ -145,6 +205,23 @@ class MaxClient(ApiMixin, WebSocketMixin):
145
205
  except Exception:
146
206
  self.logger.exception("Error closing client")
147
207
 
208
+ def _create_safe_task(self, coro: Awaitable[Any], *, name: str | None = None):
209
+ async def runner():
210
+ try:
211
+ return await coro
212
+ except asyncio.CancelledError:
213
+ raise
214
+ except Exception as e:
215
+ self.logger.error(
216
+ f"Unhandled exception in task {name or coro}: {e}",
217
+ exc_info=e,
218
+ )
219
+ return None
220
+
221
+ task = asyncio.create_task(runner(), name=name)
222
+ self._background_tasks.add(task)
223
+ return task
224
+
148
225
  async def start(self) -> None:
149
226
  """
150
227
  Запускает клиент, подключается к WebSocket, авторизует
@@ -171,7 +248,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
171
248
  self.logger.debug("Calling on_start handler")
172
249
  result = self._on_start_handler()
173
250
  if asyncio.iscoroutine(result):
174
- await result
251
+ await self._safe_execute(result, context="on_start handler")
175
252
 
176
253
  ping_task = asyncio.create_task(self._send_interactive_ping())
177
254
  ping_task.add_done_callback(self._log_task_exception)
pymax/exceptions.py CHANGED
@@ -39,15 +39,6 @@ class SocketSendError(Exception):
39
39
  super().__init__("Send and wait failed (socket)")
40
40
 
41
41
 
42
- class LoginError(Exception):
43
- """
44
- Исключение, вызываемое при ошибке авторизации.
45
- """
46
-
47
- def __init__(self, message: str) -> None:
48
- super().__init__(f"Login error: {message}")
49
-
50
-
51
42
  class ResponseError(Exception):
52
43
  """
53
44
  Исключение, вызываемое при ошибке в ответе от сервера.
@@ -64,3 +55,54 @@ class ResponseStructureError(Exception):
64
55
 
65
56
  def __init__(self, message: str) -> None:
66
57
  super().__init__(f"Response structure error: {message}")
58
+
59
+
60
+ class Error(Exception):
61
+ """
62
+ Базовое исключение для ошибок PyMax.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ error: str,
68
+ message: str,
69
+ title: str,
70
+ localized_message: str | None = None,
71
+ ) -> None:
72
+ self.error = error
73
+ self.message = message
74
+ self.title = title
75
+ self.localized_message = localized_message
76
+
77
+ parts = []
78
+ if localized_message:
79
+ parts.append(localized_message)
80
+ if message:
81
+ parts.append(message)
82
+ if title:
83
+ parts.append(f"({title})")
84
+ parts.append(f"[{error}]")
85
+
86
+ super().__init__("PyMax Error: " + " ".join(parts))
87
+
88
+
89
+ class RateLimitError(Error):
90
+ """
91
+ Исключение, вызываемое при превышении лимита запросов.
92
+ """
93
+
94
+ def __init__(
95
+ self, error: str, message: str, title: str, localized_message: str | None = None
96
+ ) -> None:
97
+ super().__init__(error, message, title, localized_message)
98
+
99
+
100
+ class LoginError(Error):
101
+ """
102
+ Исключение, вызываемое при ошибке авторизации.
103
+ """
104
+
105
+ def __init__(
106
+ self, error: str, message: str, title: str, localized_message: str | None = None
107
+ ) -> None:
108
+ super().__init__(error, message, title, localized_message)
pymax/files.py CHANGED
@@ -9,7 +9,9 @@ from typing_extensions import override
9
9
 
10
10
 
11
11
  class BaseFile(ABC):
12
- def __init__(self, url: str | None = None, path: str | None = None) -> None:
12
+ def __init__(
13
+ self, url: str | None = None, path: str | None = None
14
+ ) -> None:
13
15
  self.url = url
14
16
  self.path = path
15
17
 
@@ -45,7 +47,9 @@ class Photo(BaseFile):
45
47
  ".bmp",
46
48
  } # FIXME: костыль ✅
47
49
 
48
- def __init__(self, url: str | None = None, path: str | None = None) -> None:
50
+ def __init__(
51
+ self, url: str | None = None, path: str | None = None
52
+ ) -> None:
49
53
  super().__init__(url, path)
50
54
 
51
55
  def validate_photo(self) -> tuple[str, str] | None:
@@ -67,7 +71,9 @@ class Photo(BaseFile):
67
71
  mime_type = mimetypes.guess_type(self.url)[0]
68
72
 
69
73
  if not mime_type or not mime_type.startswith("image/"):
70
- raise ValueError(f"URL does not appear to be an image: {self.url}")
74
+ raise ValueError(
75
+ f"URL does not appear to be an image: {self.url}"
76
+ )
71
77
 
72
78
  return (extension[1:], mime_type)
73
79
  return None
@@ -84,7 +90,9 @@ class Video(BaseFile):
84
90
 
85
91
 
86
92
  class File(BaseFile):
87
- def __init__(self, url: str | None = None, path: str | None = None) -> None:
93
+ def __init__(
94
+ self, url: str | None = None, path: str | None = None
95
+ ) -> None:
88
96
  self.file_name: str = ""
89
97
  if path:
90
98
  self.file_name = Path(path).name
pymax/formatter.py ADDED
@@ -0,0 +1,30 @@
1
+ import logging
2
+ from typing import ClassVar
3
+
4
+
5
+ class ColoredFormatter(logging.Formatter):
6
+ COLORS: ClassVar = {
7
+ "DEBUG": "\033[37m",
8
+ "INFO": "\033[36m",
9
+ "WARNING": "\033[33m",
10
+ "ERROR": "\033[31m",
11
+ "CRITICAL": "\033[41m",
12
+ }
13
+
14
+ RESET = "\033[0m"
15
+ DIM = "\033[2m"
16
+ BOLD = "\033[1m"
17
+
18
+ def format(self, record: logging.LogRecord) -> str:
19
+ level_color = self.COLORS.get(record.levelname, self.RESET)
20
+ time_color = self.DIM
21
+ name_color = "\033[35m"
22
+ message_color = self.RESET
23
+
24
+ log = (
25
+ f"{time_color}{self.formatTime(record, '%H:%M:%S')}{self.RESET} "
26
+ f"[{level_color}{record.levelname}{self.RESET}] "
27
+ f"{name_color}{record.name}{self.RESET}: "
28
+ f"{message_color}{record.getMessage()}{self.RESET}"
29
+ )
30
+ return log
pymax/interfaces.py CHANGED
@@ -9,8 +9,6 @@ from typing import TYPE_CHECKING, Any, Literal
9
9
 
10
10
  import websockets
11
11
 
12
- from pymax.static.constant import DEFAULT_USER_AGENT
13
-
14
12
  from .filters import Filter
15
13
  from .payloads import UserAgentPayload
16
14
  from .static.constant import DEFAULT_TIMEOUT
@@ -43,6 +41,7 @@ class ClientProtocol(ABC):
43
41
  self.registration: bool
44
42
  self.first_name: str
45
43
  self.last_name: str | None
44
+ self._token: str | None
46
45
  self._work_dir: str
47
46
  self._database_path: Path
48
47
  self._ws: websockets.ClientConnection | None = None
@@ -50,14 +49,15 @@ class ClientProtocol(ABC):
50
49
  self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
51
50
  self._recv_task: asyncio.Task[Any] | None = None
52
51
  self._incoming: asyncio.Queue[dict[str, Any]] | None = None
53
- self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
52
+ self._file_upload_waiters: dict[
53
+ int, asyncio.Future[dict[str, Any]]
54
+ ] = {}
54
55
  self.user_agent = UserAgentPayload()
55
56
  self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
56
57
  self._outgoing_task: asyncio.Task[Any] | None = None
57
58
  self._error_count: int = 0
58
59
  self._circuit_breaker: bool = False
59
60
  self._last_error_time: float = 0.0
60
- self.user_agent = DEFAULT_USER_AGENT
61
61
  self._session_id: int
62
62
  self._action_id: int = 0
63
63
  self._current_screen: str = "chats_list_tab"
@@ -70,7 +70,9 @@ class ClientProtocol(ABC):
70
70
  self._on_message_delete_handlers: list[
71
71
  tuple[Callable[[Message], Any], Filter | None]
72
72
  ] = []
73
- self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
73
+ self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = (
74
+ None
75
+ )
74
76
  self._background_tasks: set[asyncio.Task[Any]] = set()
75
77
  self._ssl_context: ssl.SSLContext
76
78
  self._socket: socket.socket | None = None
@@ -99,3 +101,9 @@ class ClientProtocol(ABC):
99
101
  max_retries: int = 3,
100
102
  ) -> Message | None:
101
103
  pass
104
+
105
+ @abstractmethod
106
+ def _create_safe_task(
107
+ self, coro: Awaitable[Any], name: str | None = None
108
+ ) -> asyncio.Task[Any]:
109
+ pass
pymax/mixins/auth.py CHANGED
@@ -3,17 +3,15 @@ import re
3
3
  import sys
4
4
  from typing import Any
5
5
 
6
+ from pymax.exceptions import Error
6
7
  from pymax.interfaces import ClientProtocol
8
+ from pymax.mixins.utils import MixinsUtils
7
9
  from pymax.payloads import RegisterPayload, RequestCodePayload, SendCodePayload
8
10
  from pymax.static.constant import PHONE_REGEX
9
11
  from pymax.static.enum import AuthType, Opcode
10
12
 
11
13
 
12
14
  class AuthMixin(ClientProtocol):
13
- def __init__(self, token: str | None = None, *args, **kwargs) -> None:
14
- super().__init__(*args, **kwargs)
15
- self._token = token
16
-
17
15
  def _check_phone(self) -> bool:
18
16
  return bool(re.match(PHONE_REGEX, self.phone))
19
17
 
@@ -30,6 +28,9 @@ class AuthMixin(ClientProtocol):
30
28
  data = await self._send_and_wait(
31
29
  opcode=Opcode.AUTH_REQUEST, payload=payload
32
30
  )
31
+ if data.get("payload", {}).get("error"):
32
+ MixinsUtils.handle_error(data)
33
+
33
34
  self.logger.debug(
34
35
  "Code request response opcode=%s seq=%s",
35
36
  data.get("opcode"),
@@ -56,6 +57,9 @@ class AuthMixin(ClientProtocol):
56
57
  ).model_dump(by_alias=True)
57
58
 
58
59
  data = await self._send_and_wait(opcode=Opcode.AUTH, payload=payload)
60
+ if data.get("payload", {}).get("error"):
61
+ MixinsUtils.handle_error(data)
62
+
59
63
  self.logger.debug(
60
64
  "Send code response opcode=%s seq=%s",
61
65
  data.get("opcode"),
@@ -113,6 +117,9 @@ class AuthMixin(ClientProtocol):
113
117
  data = await self._send_and_wait(
114
118
  opcode=Opcode.AUTH_CONFIRM, payload=payload
115
119
  )
120
+ if data.get("payload", {}).get("error"):
121
+ MixinsUtils.handle_error(data)
122
+
116
123
  self.logger.debug(
117
124
  "Registration info response opcode=%s seq=%s",
118
125
  data.get("opcode"),
@@ -121,9 +128,7 @@ class AuthMixin(ClientProtocol):
121
128
  payload_data = data.get("payload")
122
129
  if isinstance(payload_data, dict):
123
130
  return payload_data
124
- else:
125
- self.logger.error("Invalid payload data received")
126
- raise ValueError("Invalid payload data received")
131
+ raise ValueError("Invalid payload data received")
127
132
  except Exception:
128
133
  self.logger.error("Submit registration info failed", exc_info=True)
129
134
  raise RuntimeError("Submit registration info failed")
pymax/mixins/channel.py CHANGED
@@ -1,7 +1,9 @@
1
- from pymax.exceptions import ResponseError, ResponseStructureError
1
+ from pymax.exceptions import Error, ResponseError, ResponseStructureError
2
2
  from pymax.interfaces import ClientProtocol
3
+ from pymax.mixins.utils import MixinsUtils
3
4
  from pymax.payloads import (
4
5
  GetGroupMembersPayload,
6
+ JoinChatPayload,
5
7
  ResolveLinkPayload,
6
8
  SearchGroupMembersPayload,
7
9
  )
@@ -32,12 +34,32 @@ class ChannelMixin(ClientProtocol):
32
34
  link=f"https://max.ru/{name}",
33
35
  ).model_dump(by_alias=True)
34
36
 
35
- data = await self._send_and_wait(
36
- opcode=Opcode.LINK_INFO, payload=payload
37
- )
38
- if error := data.get("payload", {}).get("error"):
39
- self.logger.error("Resolve link error: %s", error)
40
- return False
37
+ data = await self._send_and_wait(opcode=Opcode.LINK_INFO, payload=payload)
38
+ if data.get("payload", {}).get("error"):
39
+ MixinsUtils.handle_error(data)
40
+ return True
41
+
42
+ async def join_channel(self, link: str) -> bool:
43
+ """
44
+ Присоединяется к каналу по ссылке
45
+
46
+ Args:
47
+ link (str): Ссылка на канал
48
+
49
+ Exceptions:
50
+ ResponseError: Ошибка в ответе сервера
51
+ ResponseStructureError: Ошибка структуры ответа сервера
52
+
53
+ Returns:
54
+ bool: True, если присоединение прошло успешно
55
+ """
56
+ payload = JoinChatPayload(
57
+ link=link,
58
+ ).model_dump(by_alias=True)
59
+
60
+ data = await self._send_and_wait(opcode=Opcode.CHAT_JOIN, payload=payload)
61
+ if data.get("payload", {}).get("error"):
62
+ MixinsUtils.handle_error(data)
41
63
  return True
42
64
 
43
65
  async def _query_members(
@@ -65,9 +87,7 @@ class ChannelMixin(ClientProtocol):
65
87
  if isinstance(members, list):
66
88
  for item in members:
67
89
  if not isinstance(item, dict):
68
- raise ResponseStructureError(
69
- "Invalid member structure in response"
70
- )
90
+ raise ResponseStructureError("Invalid member structure in response")
71
91
  member_list.append(Member.from_dict(item))
72
92
  else:
73
93
  raise ResponseStructureError("Invalid members type in response")
@@ -91,9 +111,7 @@ class ChannelMixin(ClientProtocol):
91
111
  Returns:
92
112
  list[Member]: Список участников канала
93
113
  """
94
- payload = GetGroupMembersPayload(
95
- chat_id=chat_id, marker=marker, count=count
96
- )
114
+ payload = GetGroupMembersPayload(chat_id=chat_id, marker=marker, count=count)
97
115
  return await self._query_members(payload)
98
116
 
99
117
  async def find_members(