maxapi-python 1.1.13__py3-none-any.whl → 1.1.15__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.13
3
+ Version: 1.1.15
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
@@ -145,7 +145,7 @@ if __name__ == "__main__":
145
145
 
146
146
  ## Документация
147
147
 
148
- [WIP](https://ink-developer.github.io/)
148
+ [WIP](https://ink-developer.github.io/PyMax)
149
149
 
150
150
  ## Лицензия
151
151
 
@@ -0,0 +1,30 @@
1
+ pymax/__init__.py,sha256=jXY_nQKTdCOqXqJWNkyNIudoxfQO5p8wNmZYbuO1Rgs,980
2
+ pymax/core.py,sha256=Yx8pLJ6s8MfxxaQrw5nbSBaYP5m9yZz51O78bUmK32I,8495
3
+ pymax/crud.py,sha256=wmJh8MPi3L_HbYp7MJP0eXfDcnjgfkLDa9rHAmXtkow,3219
4
+ pymax/exceptions.py,sha256=PWEjx7d70410XMvyVVyQUwR88CTgm8MUQ4RyInTut_M,2046
5
+ pymax/files.py,sha256=oKWylKZUPlChWIo2PUHxoq4dcEbg4hAD6ITZCkiN6Xg,3086
6
+ pymax/filters.py,sha256=4hehzyQlyxBxLiEhtSH_KQdFHx8gYaqyIOOD72EsSS0,1536
7
+ pymax/formatting.py,sha256=hhtmakfcQDzQRsAckPunnJOCKY2lFdLeIp8Yw53yY8s,2522
8
+ pymax/interfaces.py,sha256=onU6yxkU7sUjSeZWlHflONzZiWIspmXYS5Byeuvscvc,3288
9
+ pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
10
+ pymax/navigation.py,sha256=4ia6RGY2pXMArboNhHkbWlWX7LtcYK1VGVXorPX0Pb4,5747
11
+ pymax/payloads.py,sha256=BwrZKVwsoBzMLWlskARMsk4xQ8bpnizoPbv_P4ez01Q,6190
12
+ pymax/types.py,sha256=mM2cbdwcevketJ-O1F_jeNe7WvYpooV0F1ojbELDdvE,31141
13
+ pymax/utils.py,sha256=r6Gm-fol6d4LdcdC12j6kQQNi1ywg7WVe24kwPh919A,1455
14
+ pymax/mixins/__init__.py,sha256=xvjcq-lFVHCPss_t8xxXya0OJnsh-owlBqtUlrXSCcw,695
15
+ pymax/mixins/auth.py,sha256=KlvVjdK3Hj7LeiCCWpGbQ_oPsww6KXQ_L_SxWD-yW3k,6526
16
+ pymax/mixins/channel.py,sha256=2oSLjZp0qm4lKAoFKGivhldajTv0F59_ozfukw7OLTI,4478
17
+ pymax/mixins/group.py,sha256=RAluoZUIh0KCKy37R0efjCMm0kptEjDyjjkDF7c97No,10533
18
+ pymax/mixins/handler.py,sha256=xlfZ9UX1iFaq7gh4E9hxPtGRwj-O2hiqx-C98IxTvQE,3877
19
+ pymax/mixins/message.py,sha256=-ixtb1TrNrNs3kZUe4fYdPVW3I5WdHiao9w-r_CfO8w,24369
20
+ pymax/mixins/self.py,sha256=TU1lWct5z9rNSsB4aVFial7UME3sfffTWlRAR2jpan8,1196
21
+ pymax/mixins/socket.py,sha256=OYhzOOvFgMOW4sR0wGPbObcFeoESd4pOyJAoIWSx6kU,23025
22
+ pymax/mixins/telemetry.py,sha256=kxsecLhDYeRGX5YviyXo4zJKQyjoyPkmPa_-vDrqMQQ,3625
23
+ pymax/mixins/user.py,sha256=CgFrcvpCefEB4bbuxLdcxW3W_ZZs5gupq74S45cU6Es,5538
24
+ pymax/mixins/websocket.py,sha256=c0cxyR2QKmv87JIcr4ZIgz-M7Oa5-FgyE5Ex0i5fOYw,16541
25
+ pymax/static/constant.py,sha256=Q1NrmaRj17Gdhk3FmUp3HIwrad1TDorq3wFdQlOCzN8,1027
26
+ pymax/static/enum.py,sha256=9DgJ8BNwLrqxTqQjpdq1vC8YYgFDlL3VFDOfgHsWQqY,4480
27
+ maxapi_python-1.1.15.dist-info/METADATA,sha256=lt3zV8IXZTKOT-v21Bw90vYl1uRkLtbZTmayergCpV8,6052
28
+ maxapi_python-1.1.15.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
29
+ maxapi_python-1.1.15.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
30
+ maxapi_python-1.1.15.dist-info/RECORD,,
pymax/__init__.py CHANGED
@@ -3,16 +3,18 @@ Python wrapper для API мессенджера Max
3
3
  """
4
4
 
5
5
  from .core import (
6
- InvalidPhoneError,
7
6
  MaxClient,
8
7
  SocketMaxClient,
8
+ )
9
+ from .exceptions import (
10
+ InvalidPhoneError,
11
+ LoginError,
9
12
  WebSocketNotConnectedError,
10
13
  )
11
- from .static import (
14
+ from .static.enum import (
12
15
  AccessType,
13
16
  AuthType,
14
17
  ChatType,
15
- Constants,
16
18
  DeviceType,
17
19
  ElementType,
18
20
  MessageStatus,
@@ -38,13 +40,14 @@ __all__ = [
38
40
  "Channel",
39
41
  "Chat",
40
42
  "ChatType",
41
- "Constants",
42
43
  "DeviceType",
43
44
  "Dialog",
44
45
  "Element",
45
46
  "ElementType",
46
47
  # Исключения
47
48
  "InvalidPhoneError",
49
+ "LoginError",
50
+ "WebSocketNotConnectedError",
48
51
  # Клиент
49
52
  "MaxClient",
50
53
  "Message",
@@ -53,5 +56,4 @@ __all__ = [
53
56
  "Opcode",
54
57
  "SocketMaxClient",
55
58
  "User",
56
- "WebSocketNotConnectedError",
57
59
  ]
pymax/core.py CHANGED
@@ -3,22 +3,20 @@ import logging
3
3
  import socket
4
4
  import ssl
5
5
  import time
6
- from collections.abc import Awaitable, Callable
7
6
  from pathlib import Path
8
- from typing import TYPE_CHECKING, Any
7
+ from typing import Literal
9
8
 
10
9
  from typing_extensions import override
11
10
 
12
11
  from .crud import Database
13
- from .exceptions import InvalidPhoneError, WebSocketNotConnectedError
12
+ from .exceptions import InvalidPhoneError
14
13
  from .mixins import ApiMixin, SocketMixin, WebSocketMixin
15
- from .static import Constants
16
- from .types import Channel, Chat, Dialog, Me, Message, User
17
-
18
- if TYPE_CHECKING:
19
- import websockets
20
-
21
- from .filters import Filter
14
+ from .payloads import UserAgentPayload
15
+ from .static.constant import (
16
+ HOST,
17
+ PORT,
18
+ WEBSOCKET_URI,
19
+ )
22
20
 
23
21
  logger = logging.getLogger(__name__)
24
22
 
@@ -34,12 +32,17 @@ class MaxClient(ApiMixin, WebSocketMixin):
34
32
  work_dir (str, optional): Рабочая директория для хранения базы данных. По умолчанию ".".
35
33
  logger (logging.Logger | None): Пользовательский логгер. Если не передан — используется
36
34
  логгер модуля с именем f"{__name__}.MaxClient".
37
- headers (dict[str, Any] | None): Заголовки для подключения к WebSocket. По умолчанию
38
- Constants.DEFAULT_USER_AGENT.value.
35
+ headers (UserAgentPayload): Заголовки для подключения к WebSocket.
39
36
  token (str | None, optional): Токен авторизации. Если не передан, будет выполнен
40
37
  процесс логина по номеру телефона.
41
38
  host (str, optional): Хост API сервера. По умолчанию Constants.HOST.value.
42
39
  port (int, optional): Порт API сервера. По умолчанию Constants.PORT.value.
40
+ registration (bool, optional): Флаг регистрации нового пользователя. По умолчанию False.
41
+ first_name (str, optional): Имя пользователя для регистрации. Требуется, если registration=True.
42
+ last_name (str | None, optional): Фамилия пользователя для регистрации.
43
+ send_fake_telemetry (bool, optional): Флаг отправки фейковой телеметрии. По умолчанию True.
44
+ proxy (str | Literal[True] | None, optional): Прокси для подключения к WebSocket.
45
+ (См. https://websockets.readthedocs.io/en/stable/topics/proxies.html).
43
46
 
44
47
  Raises:
45
48
  InvalidPhoneError: Если формат номера телефона неверный.
@@ -48,57 +51,49 @@ class MaxClient(ApiMixin, WebSocketMixin):
48
51
  def __init__(
49
52
  self,
50
53
  phone: str,
51
- uri: str = Constants.WEBSOCKET_URI.value,
52
- headers: dict[str, Any] | None = Constants.DEFAULT_USER_AGENT.value,
54
+ uri: str = WEBSOCKET_URI,
55
+ headers: UserAgentPayload = UserAgentPayload(),
53
56
  token: str | None = None,
54
57
  send_fake_telemetry: bool = True,
55
- host: str = Constants.HOST.value,
56
- port: int = Constants.PORT.value,
58
+ host: str = HOST,
59
+ port: int = PORT,
60
+ proxy: str | Literal[True] | None = None,
57
61
  work_dir: str = ".",
62
+ registration: bool = False,
63
+ first_name: str = "",
64
+ last_name: str | None = None,
58
65
  logger: logging.Logger | None = None,
59
66
  ) -> 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)
60
70
  self.uri: str = uri
61
- self.is_connected: bool = False
62
71
  self.phone: str = phone
63
- self.chats: list[Chat] = []
64
- self.dialogs: list[Dialog] = []
65
- self.channels: list[Channel] = []
66
- self.me: Me | None = None
67
- self._users: dict[int, User] = {}
68
72
  if not self._check_phone():
69
73
  raise InvalidPhoneError(self.phone)
70
74
  self.host: str = host
71
75
  self.port: int = port
76
+ self.registration: bool = registration
77
+ self.first_name: str = first_name
78
+ self.last_name: str | None = last_name
79
+ self.proxy: str | Literal[True] | None = proxy
72
80
  self._work_dir: str = work_dir
73
81
  self._database_path: Path = Path(work_dir) / "session.db"
74
82
  self._database_path.parent.mkdir(parents=True, exist_ok=True)
75
83
  self._database_path.touch(exist_ok=True)
76
84
  self._database = Database(self._work_dir)
77
- self._ws: websockets.ClientConnection | None = None
78
- self._seq: int = 0
79
- self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
80
- self._recv_task: asyncio.Task[Any] | None = None
81
- self._incoming: asyncio.Queue[dict[str, Any]] | None = None
85
+ self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
86
+ self._outgoing_task: asyncio.Task[Any] | None = None
87
+ self._error_count: int = 0
88
+ self._circuit_breaker: bool = False
89
+ self._last_error_time: float = 0.0
82
90
  self._device_id = self._database.get_device_id()
91
+ self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
83
92
  self._token = self._database.get_auth_token() or token
84
93
  self.user_agent = headers
85
-
86
94
  self._send_fake_telemetry: bool = send_fake_telemetry
87
95
  self._session_id: int = int(time.time() * 1000)
88
96
  self._action_id: int = 1
89
- self._current_screen: str = "chats_list_tab"
90
-
91
- self._on_message_handlers: list[
92
- tuple[Callable[[Message], Any], Filter | None]
93
- ] = []
94
- self._on_message_edit_handlers: list[
95
- tuple[Callable[[Message], Any], Filter | None]
96
- ] = []
97
- self._on_message_delete_handlers: list[
98
- tuple[Callable[[Message], Any], Filter | None]
99
- ] = []
100
- self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
101
- self._background_tasks: set[asyncio.Task[Any]] = set()
102
97
  self._ssl_context = ssl.create_default_context()
103
98
  self._ssl_context.set_ciphers("DEFAULT")
104
99
  self._ssl_context.check_hostname = True
@@ -106,16 +101,14 @@ class MaxClient(ApiMixin, WebSocketMixin):
106
101
  self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
107
102
  self._ssl_context.load_default_certs()
108
103
  self._socket: socket.socket | None = None
109
- self.logger = logger or logging.getLogger(f"{__name__}.MaxClient")
110
104
  self._setup_logger()
111
-
112
105
  self.logger.debug(
113
- "Initialized MaxClient uri=%s work_dir=%s", self.uri, self._work_dir
106
+ "Initialized MaxClient uri=%s work_dir=%s",
107
+ self.uri,
108
+ self._work_dir,
114
109
  )
115
110
 
116
111
  def _setup_logger(self) -> None:
117
- self.logger.setLevel(logging.INFO)
118
-
119
112
  if not logger.handlers:
120
113
  handler = logging.StreamHandler()
121
114
  formatter = logging.Formatter(
@@ -139,6 +132,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
139
132
  await self._recv_task
140
133
  except asyncio.CancelledError:
141
134
  self.logger.debug("recv_task cancelled")
135
+ if self._outgoing_task:
136
+ self._outgoing_task.cancel()
137
+ try:
138
+ await self._outgoing_task
139
+ except asyncio.CancelledError:
140
+ self.logger.debug("outgoing_task cancelled")
142
141
  if self._ws:
143
142
  await self._ws.close()
144
143
  self.is_connected = False
@@ -155,6 +154,11 @@ class MaxClient(ApiMixin, WebSocketMixin):
155
154
  self.logger.info("Client starting")
156
155
  await self._connect(self.user_agent)
157
156
 
157
+ if self.registration:
158
+ if not self.first_name:
159
+ raise ValueError("First name is required for registration")
160
+ await self._register(self.first_name, self.last_name)
161
+
158
162
  if self._token and self._database.get_auth_token() is None:
159
163
  self._database.update_auth_token(self._device_id, self._token)
160
164
 
@@ -170,18 +174,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
170
174
  await result
171
175
 
172
176
  ping_task = asyncio.create_task(self._send_interactive_ping())
177
+ ping_task.add_done_callback(self._log_task_exception)
173
178
  self._background_tasks.add(ping_task)
174
179
  if self._send_fake_telemetry:
175
180
  telemetry_task = asyncio.create_task(self._start())
181
+ telemetry_task.add_done_callback(self._log_task_exception)
176
182
  self._background_tasks.add(telemetry_task)
177
- telemetry_task.add_done_callback(
178
- lambda t: self._background_tasks.discard(t)
179
- or self._log_task_exception(t)
180
- )
181
- ping_task.add_done_callback(
182
- lambda t: self._background_tasks.discard(t)
183
- or self._log_task_exception(t)
184
- )
185
183
  await self._wait_forever()
186
184
  except Exception:
187
185
  self.logger.exception("Client start failed")
pymax/crud.py CHANGED
@@ -1,10 +1,11 @@
1
+ from typing import cast
1
2
  from uuid import UUID
2
3
 
3
4
  from sqlalchemy.engine.base import Engine
4
5
  from sqlmodel import Session, SQLModel, create_engine, select
5
6
 
6
7
  from .models import Auth
7
- from .static import DeviceType
8
+ from .static.enum import DeviceType
8
9
 
9
10
 
10
11
  class Database:
@@ -14,11 +15,6 @@ class Database:
14
15
  self.create_all()
15
16
  self._ensure_single_auth()
16
17
 
17
- self.workdir = workdir
18
- self.engine = self.get_engine(workdir)
19
- self.create_all()
20
- self._ensure_single_auth()
21
-
22
18
  def create_all(self) -> None:
23
19
  SQLModel.metadata.create_all(self.engine)
24
20
 
@@ -30,11 +26,14 @@ class Database:
30
26
 
31
27
  def get_auth_token(self) -> str | None:
32
28
  with self.get_session() as session:
33
- return session.exec(select(Auth.token)).first()
29
+ token = cast(str | None, session.exec(select(Auth.token)).first())
30
+ return token
34
31
 
35
32
  def get_device_id(self) -> UUID:
36
33
  with self.get_session() as session:
37
- device_id = session.exec(select(Auth.device_id)).first()
34
+ device_id = cast(
35
+ UUID | None, session.exec(select(Auth.device_id)).first()
36
+ )
38
37
  if device_id is None:
39
38
  auth = Auth()
40
39
  session.add(auth)
@@ -52,7 +51,9 @@ class Database:
52
51
 
53
52
  def update_auth_token(self, device_id: UUID, token: str) -> None:
54
53
  with self.get_session() as session:
55
- auth = session.exec(select(Auth).where(Auth.device_id == device_id)).first()
54
+ auth = session.exec(
55
+ select(Auth).where(Auth.device_id == device_id)
56
+ ).first()
56
57
  if auth:
57
58
  auth.token = token
58
59
  session.add(auth)
pymax/exceptions.py CHANGED
@@ -46,3 +46,21 @@ class LoginError(Exception):
46
46
 
47
47
  def __init__(self, message: str) -> None:
48
48
  super().__init__(f"Login error: {message}")
49
+
50
+
51
+ class ResponseError(Exception):
52
+ """
53
+ Исключение, вызываемое при ошибке в ответе от сервера.
54
+ """
55
+
56
+ def __init__(self, message: str) -> None:
57
+ super().__init__(f"Response error: {message}")
58
+
59
+
60
+ class ResponseStructureError(Exception):
61
+ """
62
+ Исключение, вызываемое при неверной структуре ответа от сервера.
63
+ """
64
+
65
+ def __init__(self, message: str) -> None:
66
+ super().__init__(f"Response structure error: {message}")
pymax/files.py CHANGED
@@ -22,7 +22,10 @@ class BaseFile(ABC):
22
22
  @abstractmethod
23
23
  async def read(self) -> bytes:
24
24
  if self.url:
25
- async with ClientSession() as session, session.get(self.url) as response:
25
+ async with (
26
+ ClientSession() as session,
27
+ session.get(self.url) as response,
28
+ ):
26
29
  response.raise_for_status()
27
30
  return await response.read()
28
31
  elif self.path:
@@ -81,6 +84,18 @@ class Video(BaseFile):
81
84
 
82
85
 
83
86
  class File(BaseFile):
87
+ def __init__(self, url: str | None = None, path: str | None = None) -> None:
88
+ self.file_name: str = ""
89
+ if path:
90
+ self.file_name = Path(path).name
91
+ elif url:
92
+ self.file_name = Path(url).name
93
+
94
+ if not self.file_name:
95
+ raise ValueError("Either url or path must be provided.")
96
+
97
+ super().__init__(url, path)
98
+
84
99
  @override
85
100
  async def read(self) -> bytes:
86
101
  return await super().read()
pymax/filters.py CHANGED
@@ -1,4 +1,4 @@
1
- from .static import MessageStatus, MessageType
1
+ from .static.enum import MessageStatus, MessageType
2
2
  from .types import Message
3
3
 
4
4
 
@@ -30,13 +30,18 @@ class Filter:
30
30
  text not in message.text for text in self.text
31
31
  ):
32
32
  return False
33
- if self.text_contains is not None and self.text_contains not in message.text:
33
+ if (
34
+ self.text_contains is not None
35
+ and self.text_contains not in message.text
36
+ ):
34
37
  return False
35
38
  if self.status is not None and message.status != self.status:
36
39
  return False
37
40
  if self.type is not None and message.type != self.type:
38
41
  return False
39
- if self.reaction_info is not None and message.reactionInfo is None: # noqa: SIM103
42
+ if (
43
+ self.reaction_info is not None and message.reactionInfo is None
44
+ ): # noqa: SIM103
40
45
  return False
41
46
 
42
47
  return True
pymax/formatting.py CHANGED
@@ -1,6 +1,6 @@
1
1
  import re
2
2
 
3
- from pymax.static import FormattingType
3
+ from pymax.static.enum import FormattingType
4
4
  from pymax.types import Element
5
5
 
6
6
 
@@ -46,9 +46,9 @@ class Formatting:
46
46
 
47
47
  if inner_text is not None and fmt_type is not None:
48
48
  next_pos = match.end()
49
- has_newline = (next_pos < len(text) and text[next_pos] == "\n") or (
50
- next_pos == len(text)
51
- )
49
+ has_newline = (
50
+ next_pos < len(text) and text[next_pos] == "\n"
51
+ ) or (next_pos == len(text))
52
52
 
53
53
  length = len(inner_text) + (1 if has_newline else 0)
54
54
  elements.append(
pymax/interfaces.py CHANGED
@@ -1,17 +1,20 @@
1
1
  import asyncio
2
- import logging
3
2
  import socket
4
3
  import ssl
5
4
  from abc import ABC, abstractmethod
6
5
  from collections.abc import Awaitable, Callable
7
6
  from logging import Logger
8
7
  from pathlib import Path
9
- from typing import TYPE_CHECKING, Any
8
+ from typing import TYPE_CHECKING, Any, Literal
10
9
 
11
10
  import websockets
12
11
 
12
+ from pymax.static.constant import DEFAULT_USER_AGENT
13
+
13
14
  from .filters import Filter
14
- from .static import Constants
15
+ from .payloads import UserAgentPayload
16
+ from .static.constant import DEFAULT_TIMEOUT
17
+ from .static.enum import Opcode
15
18
  from .types import Channel, Chat, Dialog, Me, Message, User
16
19
 
17
20
  if TYPE_CHECKING:
@@ -26,23 +29,20 @@ class ClientProtocol(ABC):
26
29
  self.logger = logger
27
30
  self._users: dict[int, User] = {}
28
31
  self.chats: list[Chat] = []
29
- self.phone: str = ""
30
32
  self._database: Database
31
33
  self._device_id: UUID
32
- self._on_message_handlers: list[
33
- tuple[Callable[[Message], Any], Filter | None]
34
- ] = []
35
34
  self.uri: str
36
-
37
35
  self.is_connected: bool = False
38
36
  self.phone: str
39
- self.chats: list[Chat] = []
40
37
  self.dialogs: list[Dialog] = []
41
38
  self.channels: list[Channel] = []
42
39
  self.me: Me | None = None
43
40
  self.host: str
44
41
  self.port: int
45
- self._users: dict[int, User] = {}
42
+ self.proxy: str | Literal[True] | None
43
+ self.registration: bool
44
+ self.first_name: str
45
+ self.last_name: str | None
46
46
  self._work_dir: str
47
47
  self._database_path: Path
48
48
  self._ws: websockets.ClientConnection | None = None
@@ -50,12 +50,17 @@ class ClientProtocol(ABC):
50
50
  self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
51
51
  self._recv_task: asyncio.Task[Any] | None = None
52
52
  self._incoming: asyncio.Queue[dict[str, Any]] | None = None
53
- self.user_agent = Constants.DEFAULT_USER_AGENT.value
54
-
53
+ self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
54
+ self.user_agent = UserAgentPayload()
55
+ self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
56
+ self._outgoing_task: asyncio.Task[Any] | None = None
57
+ self._error_count: int = 0
58
+ self._circuit_breaker: bool = False
59
+ self._last_error_time: float = 0.0
60
+ self.user_agent = DEFAULT_USER_AGENT
55
61
  self._session_id: int
56
62
  self._action_id: int = 0
57
63
  self._current_screen: str = "chats_list_tab"
58
-
59
64
  self._on_message_handlers: list[
60
65
  tuple[Callable[[Message], Any], Filter | None]
61
66
  ] = []
@@ -73,13 +78,24 @@ class ClientProtocol(ABC):
73
78
  @abstractmethod
74
79
  async def _send_and_wait(
75
80
  self,
76
- opcode: int,
81
+ opcode: Opcode,
77
82
  payload: dict[str, Any],
78
83
  cmd: int = 0,
79
- timeout: float = Constants.DEFAULT_TIMEOUT.value,
84
+ timeout: float = DEFAULT_TIMEOUT,
80
85
  ) -> dict[str, Any]:
81
86
  pass
82
87
 
83
88
  @abstractmethod
84
89
  async def _get_chat(self, chat_id: int) -> Chat | None:
85
90
  pass
91
+
92
+ @abstractmethod
93
+ async def _queue_message(
94
+ self,
95
+ opcode: int,
96
+ payload: dict[str, Any],
97
+ cmd: int = 0,
98
+ timeout: float = DEFAULT_TIMEOUT,
99
+ max_retries: int = 3,
100
+ ) -> Message | None:
101
+ pass
pymax/mixins/auth.py CHANGED
@@ -1,15 +1,21 @@
1
1
  import asyncio
2
2
  import re
3
+ import sys
3
4
  from typing import Any
4
5
 
5
6
  from pymax.interfaces import ClientProtocol
6
- from pymax.payloads import RequestCodePayload, SendCodePayload
7
- from pymax.static import AuthType, Constants, Opcode
7
+ from pymax.payloads import RegisterPayload, RequestCodePayload, SendCodePayload
8
+ from pymax.static.constant import PHONE_REGEX
9
+ from pymax.static.enum import AuthType, Opcode
8
10
 
9
11
 
10
12
  class AuthMixin(ClientProtocol):
13
+ def __init__(self, token: str | None = None, *args, **kwargs) -> None:
14
+ super().__init__(*args, **kwargs)
15
+ self._token = token
16
+
11
17
  def _check_phone(self) -> bool:
12
- return bool(re.match(Constants.PHONE_REGEX.value, self.phone))
18
+ return bool(re.match(PHONE_REGEX, self.phone))
13
19
 
14
20
  async def _request_code(
15
21
  self, phone: str, language: str = "ru"
@@ -29,7 +35,12 @@ class AuthMixin(ClientProtocol):
29
35
  data.get("opcode"),
30
36
  data.get("seq"),
31
37
  )
32
- return data.get("payload")
38
+ payload_data = data.get("payload")
39
+ if isinstance(payload_data, dict):
40
+ return payload_data
41
+ else:
42
+ self.logger.error("Invalid payload data received")
43
+ raise ValueError("Invalid payload data received")
33
44
  except Exception:
34
45
  self.logger.error("Request code failed", exc_info=True)
35
46
  raise RuntimeError("Request code failed")
@@ -50,20 +61,27 @@ class AuthMixin(ClientProtocol):
50
61
  data.get("opcode"),
51
62
  data.get("seq"),
52
63
  )
53
- return data.get("payload")
64
+ payload_data = data.get("payload")
65
+ if isinstance(payload_data, dict):
66
+ return payload_data
67
+ else:
68
+ self.logger.error("Invalid payload data received")
69
+ raise ValueError("Invalid payload data received")
54
70
  except Exception:
55
71
  self.logger.error("Send code failed", exc_info=True)
56
72
  raise RuntimeError("Send code failed")
57
73
 
58
74
  async def _login(self) -> None:
59
75
  self.logger.info("Starting login flow")
76
+
60
77
  request_code_payload = await self._request_code(self.phone)
61
78
  temp_token = request_code_payload.get("token")
62
79
  if not temp_token or not isinstance(temp_token, str):
63
80
  self.logger.critical("Failed to request code: token missing")
64
81
  raise ValueError("Failed to request code")
65
82
 
66
- code = await asyncio.to_thread(input, "Введите код: ")
83
+ print("Введите код: ", end="", flush=True)
84
+ code = await asyncio.to_thread(lambda: sys.stdin.readline().strip())
67
85
  if len(code) != 6 or not code.isdigit():
68
86
  self.logger.error("Invalid code format entered")
69
87
  raise ValueError("Invalid code format")
@@ -79,3 +97,66 @@ class AuthMixin(ClientProtocol):
79
97
  self._token = token
80
98
  self._database.update_auth_token(self._device_id, self._token)
81
99
  self.logger.info("Login successful, token saved to database")
100
+
101
+ async def _submit_reg_info(
102
+ self, first_name: str, last_name: str | None, token: str
103
+ ) -> dict[str, Any]:
104
+ try:
105
+ self.logger.info("Submitting registration info")
106
+
107
+ payload = RegisterPayload(
108
+ first_name=first_name,
109
+ last_name=last_name,
110
+ token=token,
111
+ ).model_dump(by_alias=True)
112
+
113
+ data = await self._send_and_wait(
114
+ opcode=Opcode.AUTH_CONFIRM, payload=payload
115
+ )
116
+ self.logger.debug(
117
+ "Registration info response opcode=%s seq=%s",
118
+ data.get("opcode"),
119
+ data.get("seq"),
120
+ )
121
+ payload_data = data.get("payload")
122
+ if isinstance(payload_data, dict):
123
+ return payload_data
124
+ else:
125
+ self.logger.error("Invalid payload data received")
126
+ raise ValueError("Invalid payload data received")
127
+ except Exception:
128
+ self.logger.error("Submit registration info failed", exc_info=True)
129
+ raise RuntimeError("Submit registration info failed")
130
+
131
+ async def _register(self, first_name: str, last_name: str | None = None) -> None:
132
+ self.logger.info("Starting registration flow")
133
+
134
+ request_code_payload = await self._request_code(self.phone)
135
+ temp_token = request_code_payload.get("token")
136
+
137
+ if not temp_token or not isinstance(temp_token, str):
138
+ self.logger.critical("Failed to request code: token missing")
139
+ raise ValueError("Failed to request code")
140
+
141
+ print("Введите код: ", end="", flush=True)
142
+ code = await asyncio.to_thread(lambda: sys.stdin.readline().strip())
143
+ if len(code) != 6 or not code.isdigit():
144
+ self.logger.error("Invalid code format entered")
145
+ raise ValueError("Invalid code format")
146
+
147
+ registration_response = await self._send_code(code, temp_token)
148
+ token: str | None = (
149
+ registration_response.get("tokenAttrs", {}).get("REGISTER", {}).get("token")
150
+ )
151
+ if not token:
152
+ self.logger.critical("Failed to register, token not received")
153
+ raise ValueError("Failed to register, token not received")
154
+
155
+ data = await self._submit_reg_info(first_name, last_name, token)
156
+ self._token = data.get("token")
157
+ if not self._token:
158
+ self.logger.critical("Failed to register, token not received")
159
+ raise ValueError("Failed to register, token not received")
160
+
161
+ self._database.update_auth_token(self._device_id, self._token)
162
+ self.logger.info("Registration successful, token saved to database")