maxapi-python 1.1.14__tar.gz → 1.1.15__tar.gz

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.
Files changed (51) hide show
  1. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/PKG-INFO +2 -2
  2. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/README.md +1 -1
  3. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/examples/example.py +20 -8
  4. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/pyproject.toml +1 -1
  5. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/core.py +32 -0
  6. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/files.py +15 -9
  7. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/interfaces.py +26 -4
  8. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/mixins/auth.py +69 -6
  9. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/mixins/message.py +187 -126
  10. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/mixins/socket.py +93 -0
  11. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/mixins/websocket.py +127 -29
  12. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/payloads.py +21 -9
  13. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/static/enum.py +1 -0
  14. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/.github/FUNDING.yml +0 -0
  15. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  16. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  17. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/.github/ISSUE_TEMPLATE/refactor.md +0 -0
  18. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/.github/pull_request_template.md +0 -0
  19. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/.github/workflows/publish.yml +0 -0
  20. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/.gitignore +0 -0
  21. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/.pre-commit-config.yaml +0 -0
  22. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/LICENSE +0 -0
  23. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/assets/icon.svg +0 -0
  24. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/assets/logo.svg +0 -0
  25. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/docs/api.md +0 -0
  26. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/docs/assets/icon.svg +0 -0
  27. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/docs/client.md +0 -0
  28. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/docs/examples.md +0 -0
  29. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/docs/index.md +0 -0
  30. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/docs/methods.md +0 -0
  31. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/docs/types.md +0 -0
  32. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/examples/telegram_bridge.py +0 -0
  33. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/mkdocs.yml +0 -0
  34. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/ruff.toml +0 -0
  35. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/__init__.py +0 -0
  36. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/crud.py +0 -0
  37. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/exceptions.py +0 -0
  38. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/filters.py +0 -0
  39. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/formatting.py +0 -0
  40. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/mixins/__init__.py +0 -0
  41. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/mixins/channel.py +0 -0
  42. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/mixins/group.py +0 -0
  43. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/mixins/handler.py +0 -0
  44. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/mixins/self.py +0 -0
  45. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/mixins/telemetry.py +0 -0
  46. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/mixins/user.py +0 -0
  47. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/models.py +0 -0
  48. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/navigation.py +0 -0
  49. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/static/constant.py +0 -0
  50. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/types.py +0 -0
  51. {maxapi_python-1.1.14 → maxapi_python-1.1.15}/src/pymax/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxapi-python
3
- Version: 1.1.14
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
 
@@ -123,7 +123,7 @@ if __name__ == "__main__":
123
123
 
124
124
  ## Документация
125
125
 
126
- [WIP](https://ink-developer.github.io/)
126
+ [WIP](https://ink-developer.github.io/PyMax)
127
127
 
128
128
  ## Лицензия
129
129
 
@@ -1,6 +1,8 @@
1
1
  import asyncio
2
+ import datetime
2
3
 
3
4
  from pymax import MaxClient, Message
5
+ from pymax.files import File
4
6
  from pymax.filters import Filter
5
7
  from pymax.static.enum import AttachType
6
8
 
@@ -27,14 +29,24 @@ async def handle_deleted_message(message: Message) -> None:
27
29
 
28
30
  @client.on_start
29
31
  async def handle_start() -> None:
30
- print("Client started successfully!")
31
- history = await client.fetch_history(chat_id=0)
32
- if history:
33
- for message in history:
34
- if message.attaches:
35
- for attach in message.attaches:
36
- if attach.type == AttachType.STICKER:
37
- print(attach.lottie_url)
32
+ print(f"Client started successfully at {datetime.datetime.now()}!")
33
+ file_path = "ruff.toml"
34
+ file = File(path=file_path)
35
+ msg = await client.send_message(
36
+ text="Here is the file you requested.",
37
+ chat_id=0,
38
+ attachment=file,
39
+ notify=True,
40
+ )
41
+ if msg:
42
+ print(f"File sent successfully in message ID: {msg.id}")
43
+ # history = await client.fetch_history(chat_id=0)
44
+ # if history:
45
+ # for message in history:
46
+ # if message.attaches:
47
+ # for attach in message.attaches:
48
+ # if attach.type == AttachType.STICKER:
49
+ # print(attach.lottie_url)
38
50
  # chat = await client.rework_invite_link(chat_id=0)
39
51
  # print(chat.link)
40
52
  # text = """
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "maxapi-python"
3
- version = "1.1.14"
3
+ version = "1.1.15"
4
4
  description = "Python wrapper для API мессенджера Max"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -4,6 +4,7 @@ import socket
4
4
  import ssl
5
5
  import time
6
6
  from pathlib import Path
7
+ from typing import Literal
7
8
 
8
9
  from typing_extensions import override
9
10
 
@@ -36,6 +37,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
36
37
  процесс логина по номеру телефона.
37
38
  host (str, optional): Хост API сервера. По умолчанию Constants.HOST.value.
38
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).
39
46
 
40
47
  Raises:
41
48
  InvalidPhoneError: Если формат номера телефона неверный.
@@ -50,7 +57,11 @@ class MaxClient(ApiMixin, WebSocketMixin):
50
57
  send_fake_telemetry: bool = True,
51
58
  host: str = HOST,
52
59
  port: int = PORT,
60
+ proxy: str | Literal[True] | None = None,
53
61
  work_dir: str = ".",
62
+ registration: bool = False,
63
+ first_name: str = "",
64
+ last_name: str | None = None,
54
65
  logger: logging.Logger | None = None,
55
66
  ) -> None:
56
67
  logger = logger or logging.getLogger(f"{__name__}.MaxClient")
@@ -62,12 +73,22 @@ class MaxClient(ApiMixin, WebSocketMixin):
62
73
  raise InvalidPhoneError(self.phone)
63
74
  self.host: str = host
64
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
65
80
  self._work_dir: str = work_dir
66
81
  self._database_path: Path = Path(work_dir) / "session.db"
67
82
  self._database_path.parent.mkdir(parents=True, exist_ok=True)
68
83
  self._database_path.touch(exist_ok=True)
69
84
  self._database = Database(self._work_dir)
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
70
90
  self._device_id = self._database.get_device_id()
91
+ self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
71
92
  self._token = self._database.get_auth_token() or token
72
93
  self.user_agent = headers
73
94
  self._send_fake_telemetry: bool = send_fake_telemetry
@@ -111,6 +132,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
111
132
  await self._recv_task
112
133
  except asyncio.CancelledError:
113
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")
114
141
  if self._ws:
115
142
  await self._ws.close()
116
143
  self.is_connected = False
@@ -127,6 +154,11 @@ class MaxClient(ApiMixin, WebSocketMixin):
127
154
  self.logger.info("Client starting")
128
155
  await self._connect(self.user_agent)
129
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
+
130
162
  if self._token and self._database.get_auth_token() is None:
131
163
  self._database.update_auth_token(self._device_id, self._token)
132
164
 
@@ -9,9 +9,7 @@ from typing_extensions import override
9
9
 
10
10
 
11
11
  class BaseFile(ABC):
12
- def __init__(
13
- self, url: str | None = None, path: str | None = None
14
- ) -> None:
12
+ def __init__(self, url: str | None = None, path: str | None = None) -> None:
15
13
  self.url = url
16
14
  self.path = path
17
15
 
@@ -47,9 +45,7 @@ class Photo(BaseFile):
47
45
  ".bmp",
48
46
  } # FIXME: костыль ✅
49
47
 
50
- def __init__(
51
- self, url: str | None = None, path: str | None = None
52
- ) -> None:
48
+ def __init__(self, url: str | None = None, path: str | None = None) -> None:
53
49
  super().__init__(url, path)
54
50
 
55
51
  def validate_photo(self) -> tuple[str, str] | None:
@@ -71,9 +67,7 @@ class Photo(BaseFile):
71
67
  mime_type = mimetypes.guess_type(self.url)[0]
72
68
 
73
69
  if not mime_type or not mime_type.startswith("image/"):
74
- raise ValueError(
75
- f"URL does not appear to be an image: {self.url}"
76
- )
70
+ raise ValueError(f"URL does not appear to be an image: {self.url}")
77
71
 
78
72
  return (extension[1:], mime_type)
79
73
  return None
@@ -90,6 +84,18 @@ class Video(BaseFile):
90
84
 
91
85
 
92
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
+
93
99
  @override
94
100
  async def read(self) -> bytes:
95
101
  return await super().read()
@@ -5,10 +5,12 @@ from abc import ABC, abstractmethod
5
5
  from collections.abc import Awaitable, Callable
6
6
  from logging import Logger
7
7
  from pathlib import Path
8
- from typing import TYPE_CHECKING, Any
8
+ 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
+
12
14
  from .filters import Filter
13
15
  from .payloads import UserAgentPayload
14
16
  from .static.constant import DEFAULT_TIMEOUT
@@ -37,6 +39,10 @@ class ClientProtocol(ABC):
37
39
  self.me: Me | None = None
38
40
  self.host: str
39
41
  self.port: int
42
+ self.proxy: str | Literal[True] | None
43
+ self.registration: bool
44
+ self.first_name: str
45
+ self.last_name: str | None
40
46
  self._work_dir: str
41
47
  self._database_path: Path
42
48
  self._ws: websockets.ClientConnection | None = None
@@ -44,7 +50,14 @@ class ClientProtocol(ABC):
44
50
  self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
45
51
  self._recv_task: asyncio.Task[Any] | None = None
46
52
  self._incoming: asyncio.Queue[dict[str, Any]] | None = None
53
+ self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
47
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
48
61
  self._session_id: int
49
62
  self._action_id: int = 0
50
63
  self._current_screen: str = "chats_list_tab"
@@ -57,9 +70,7 @@ class ClientProtocol(ABC):
57
70
  self._on_message_delete_handlers: list[
58
71
  tuple[Callable[[Message], Any], Filter | None]
59
72
  ] = []
60
- self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = (
61
- None
62
- )
73
+ self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
63
74
  self._background_tasks: set[asyncio.Task[Any]] = set()
64
75
  self._ssl_context: ssl.SSLContext
65
76
  self._socket: socket.socket | None = None
@@ -77,3 +88,14 @@ class ClientProtocol(ABC):
77
88
  @abstractmethod
78
89
  async def _get_chat(self, chat_id: int) -> Chat | None:
79
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
@@ -1,15 +1,15 @@
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.payloads import RegisterPayload, RequestCodePayload, SendCodePayload
7
8
  from pymax.static.constant import PHONE_REGEX
8
9
  from pymax.static.enum import AuthType, Opcode
9
10
 
10
11
 
11
12
  class AuthMixin(ClientProtocol):
12
-
13
13
  def __init__(self, token: str | None = None, *args, **kwargs) -> None:
14
14
  super().__init__(*args, **kwargs)
15
15
  self._token = token
@@ -55,9 +55,7 @@ class AuthMixin(ClientProtocol):
55
55
  auth_token_type=AuthType.CHECK_CODE,
56
56
  ).model_dump(by_alias=True)
57
57
 
58
- data = await self._send_and_wait(
59
- opcode=Opcode.AUTH, payload=payload
60
- )
58
+ data = await self._send_and_wait(opcode=Opcode.AUTH, payload=payload)
61
59
  self.logger.debug(
62
60
  "Send code response opcode=%s seq=%s",
63
61
  data.get("opcode"),
@@ -75,13 +73,15 @@ class AuthMixin(ClientProtocol):
75
73
 
76
74
  async def _login(self) -> None:
77
75
  self.logger.info("Starting login flow")
76
+
78
77
  request_code_payload = await self._request_code(self.phone)
79
78
  temp_token = request_code_payload.get("token")
80
79
  if not temp_token or not isinstance(temp_token, str):
81
80
  self.logger.critical("Failed to request code: token missing")
82
81
  raise ValueError("Failed to request code")
83
82
 
84
- code = await asyncio.to_thread(input, "Введите код: ")
83
+ print("Введите код: ", end="", flush=True)
84
+ code = await asyncio.to_thread(lambda: sys.stdin.readline().strip())
85
85
  if len(code) != 6 or not code.isdigit():
86
86
  self.logger.error("Invalid code format entered")
87
87
  raise ValueError("Invalid code format")
@@ -97,3 +97,66 @@ class AuthMixin(ClientProtocol):
97
97
  self._token = token
98
98
  self._database.update_auth_token(self._device_id, self._token)
99
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")