maxapi-python 0.1.0__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.
- maxapi_python-0.1.0.dist-info/METADATA +110 -0
- maxapi_python-0.1.0.dist-info/RECORD +12 -0
- maxapi_python-0.1.0.dist-info/WHEEL +5 -0
- maxapi_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- maxapi_python-0.1.0.dist-info/top_level.txt +1 -0
- pymax/__init__.py +55 -0
- pymax/core.py +600 -0
- pymax/crud.py +99 -0
- pymax/exceptions.py +20 -0
- pymax/models.py +8 -0
- pymax/static.py +86 -0
- pymax/types.py +327 -0
@@ -0,0 +1,110 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: maxapi-python
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Python wrapper для API мессенджера Max
|
5
|
+
Author-email: noxzion <negroid2281488ilikrilex@gmail.com>
|
6
|
+
License-Expression: MIT
|
7
|
+
Project-URL: Homepage, https://github.com/noxzion/PyMax
|
8
|
+
Project-URL: Repository, https://github.com/noxzion/PyMax
|
9
|
+
Project-URL: Issues, https://github.com/noxzion/PyMax/issues
|
10
|
+
Keywords: max,messenger,api,wrapper,websocket
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
12
|
+
Classifier: Operating System :: OS Independent
|
13
|
+
Requires-Python: >=3.11
|
14
|
+
Description-Content-Type: text/markdown
|
15
|
+
License-File: LICENSE
|
16
|
+
Requires-Dist: build>=1.3.0
|
17
|
+
Requires-Dist: pydocstring>=0.2.1
|
18
|
+
Requires-Dist: sqlmodel>=0.0.24
|
19
|
+
Requires-Dist: websockets>=11.0
|
20
|
+
Dynamic: license-file
|
21
|
+
|
22
|
+
## MApi - Python api wrapper для Max'a
|
23
|
+
|
24
|
+
## Установка
|
25
|
+
|
26
|
+
> [!IMPORTANT]
|
27
|
+
> Нужно иметь git для установки из репозитория
|
28
|
+
|
29
|
+
```bash
|
30
|
+
pip install git+https://github.com/noxzion/PyMax
|
31
|
+
```
|
32
|
+
|
33
|
+
Или (без git)
|
34
|
+
```bash
|
35
|
+
pip install maxapi-python
|
36
|
+
```
|
37
|
+
|
38
|
+
## Пример использования:
|
39
|
+
|
40
|
+
```python
|
41
|
+
import asyncio
|
42
|
+
|
43
|
+
from mapi import MaxClient, Message
|
44
|
+
|
45
|
+
|
46
|
+
phone = "+1234567890"
|
47
|
+
client = MaxClient(phone=phone, work_dir="cache")
|
48
|
+
|
49
|
+
|
50
|
+
async def main() -> None:
|
51
|
+
await client.start()
|
52
|
+
|
53
|
+
for chat in client.chats:
|
54
|
+
print(chat.title)
|
55
|
+
|
56
|
+
message = await client.send_message("Hello from MaxClient!", chat.id, notify=True)
|
57
|
+
|
58
|
+
await asyncio.sleep(5)
|
59
|
+
message = await client.edit_message(chat.id, message.id, "Hello from MaxClient! (edited)")
|
60
|
+
await asyncio.sleep(5)
|
61
|
+
|
62
|
+
await client.delete_message(chat.id, [message.id], for_me=False)
|
63
|
+
|
64
|
+
for dialog in client.dialogs:
|
65
|
+
print(dialog.last_message.text)
|
66
|
+
|
67
|
+
for channel in client.channels:
|
68
|
+
print(channel.title)
|
69
|
+
|
70
|
+
await client.close()
|
71
|
+
|
72
|
+
|
73
|
+
@client.on_message
|
74
|
+
async def handle_message(message: Message) -> None:
|
75
|
+
print(str(message.sender) + ": " + message.text)
|
76
|
+
|
77
|
+
|
78
|
+
@client.on_start
|
79
|
+
async def handle_start() -> None:
|
80
|
+
print("Client started successfully!")
|
81
|
+
history = await client.fetch_history(chat_id=0)
|
82
|
+
if history:
|
83
|
+
for message in history:
|
84
|
+
user_id = message.sender
|
85
|
+
user = await client.get_user(user_id)
|
86
|
+
|
87
|
+
if user:
|
88
|
+
print(f"{user.names[0].name}: {message.text}")
|
89
|
+
|
90
|
+
|
91
|
+
if __name__ == "__main__":
|
92
|
+
asyncio.run(client.start())
|
93
|
+
```
|
94
|
+
|
95
|
+
## Разработка
|
96
|
+
|
97
|
+
Сборка пакета:
|
98
|
+
|
99
|
+
```bash
|
100
|
+
python scripts/build.py
|
101
|
+
```
|
102
|
+
|
103
|
+
## Лицензия
|
104
|
+
|
105
|
+
[MIT](LICENSE)
|
106
|
+
|
107
|
+
## Авторы
|
108
|
+
|
109
|
+
- [noxzion](https://github.com/noxzion) - исходный код пакета
|
110
|
+
- [ink](https://github.com/ink-developer) - вскрытие API и документация
|
@@ -0,0 +1,12 @@
|
|
1
|
+
maxapi_python-0.1.0.dist-info/licenses/LICENSE,sha256=Ud-0SKeXO_yA02Bb1nMDnEaSGwz2OqNlfGQbk0IzqPI,1085
|
2
|
+
pymax/__init__.py,sha256=I-ZUVKBfHN-MPkLUbLPxflsvnHSFsyXdW3TmbN2_zz0,950
|
3
|
+
pymax/core.py,sha256=DI3yjlCvSipilWMescXluLbP-sDwoOD092K67dJqR38,25132
|
4
|
+
pymax/crud.py,sha256=Cn7Psw-gGN0607nqWOLlIorP_wowYWrLomlsof0gLkI,3311
|
5
|
+
pymax/exceptions.py,sha256=tiD_JD-MYSb4qFyKov-KWOm0zlD1p_gG6nf1fV-0-SY,702
|
6
|
+
pymax/models.py,sha256=7sWAmVuJjM7SPnDkpYEi8CARbTpUKbXqtWKMQdwd0w0,209
|
7
|
+
pymax/static.py,sha256=ZIPPAyr3__gtkwSgS_-hXk_oNkcVKs55tY8pSzOrSlY,1711
|
8
|
+
pymax/types.py,sha256=6EofkAY_oUnDkNh-cC9Uk05GjKgl6ThBy3_8Hjownz4,11541
|
9
|
+
maxapi_python-0.1.0.dist-info/METADATA,sha256=DOVISXKymoucnNKgU1z84wPCLA1Mcwx9rzpxBPAuKCQ,2808
|
10
|
+
maxapi_python-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
11
|
+
maxapi_python-0.1.0.dist-info/top_level.txt,sha256=bdAekZwlWiYDxQTWsAUa-yjplJDWDeWEmyhRO3R8qV4,6
|
12
|
+
maxapi_python-0.1.0.dist-info/RECORD,,
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 noxzion
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
@@ -0,0 +1 @@
|
|
1
|
+
pymax
|
pymax/__init__.py
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
"""
|
2
|
+
Python wrapper для API мессенджера Max
|
3
|
+
"""
|
4
|
+
|
5
|
+
from .core import (
|
6
|
+
InvalidPhoneError,
|
7
|
+
MaxClient,
|
8
|
+
WebSocketNotConnectedError,
|
9
|
+
)
|
10
|
+
from .static import (
|
11
|
+
AccessType,
|
12
|
+
AuthType,
|
13
|
+
ChatType,
|
14
|
+
Constants,
|
15
|
+
DeviceType,
|
16
|
+
ElementType,
|
17
|
+
MessageStatus,
|
18
|
+
MessageType,
|
19
|
+
Opcode,
|
20
|
+
)
|
21
|
+
from .types import (
|
22
|
+
Channel,
|
23
|
+
Chat,
|
24
|
+
Dialog,
|
25
|
+
Element,
|
26
|
+
Message,
|
27
|
+
User,
|
28
|
+
)
|
29
|
+
|
30
|
+
__author__ = "noxzion"
|
31
|
+
|
32
|
+
__all__ = [
|
33
|
+
# Перечисления и константы
|
34
|
+
"AccessType",
|
35
|
+
"AuthType",
|
36
|
+
# Типы данных
|
37
|
+
"Channel",
|
38
|
+
"Chat",
|
39
|
+
"ChatType",
|
40
|
+
"Constants",
|
41
|
+
"DeviceType",
|
42
|
+
"Dialog",
|
43
|
+
"Element",
|
44
|
+
"ElementType",
|
45
|
+
# Исключения
|
46
|
+
"InvalidPhoneError",
|
47
|
+
# Клиент
|
48
|
+
"MaxClient",
|
49
|
+
"Message",
|
50
|
+
"MessageStatus",
|
51
|
+
"MessageType",
|
52
|
+
"Opcode",
|
53
|
+
"User",
|
54
|
+
"WebSocketNotConnectedError",
|
55
|
+
]
|
pymax/core.py
ADDED
@@ -0,0 +1,600 @@
|
|
1
|
+
import asyncio
|
2
|
+
import json
|
3
|
+
import logging
|
4
|
+
import re
|
5
|
+
import time
|
6
|
+
from collections.abc import Awaitable, Callable
|
7
|
+
from pathlib import Path
|
8
|
+
from typing import Any
|
9
|
+
|
10
|
+
import websockets
|
11
|
+
|
12
|
+
from .crud import Database
|
13
|
+
from .exceptions import InvalidPhoneError, WebSocketNotConnectedError
|
14
|
+
from .static import AuthType, ChatType, Constants, Opcode
|
15
|
+
from .types import Channel, Chat, Dialog, Message, User
|
16
|
+
|
17
|
+
logger = logging.getLogger(__name__)
|
18
|
+
|
19
|
+
|
20
|
+
class MaxClient:
|
21
|
+
"""
|
22
|
+
Основной клиент для работы с WebSocket API сервиса Max.
|
23
|
+
|
24
|
+
|
25
|
+
Args:
|
26
|
+
phone (str): Номер телефона для авторизации.
|
27
|
+
uri (str, optional): URI WebSocket сервера. По умолчанию Constants.WEBSOCKET_URI.value.
|
28
|
+
work_dir (str, optional): Рабочая директория для хранения базы данных. По умолчанию ".".
|
29
|
+
logger (logging.Logger | None): Пользовательский логгер. Если не передан — используется
|
30
|
+
логгер модуля с именем f"{__name__}.MaxClient".
|
31
|
+
|
32
|
+
Raises:
|
33
|
+
InvalidPhoneError: Если формат номера телефона неверный.
|
34
|
+
"""
|
35
|
+
|
36
|
+
def __init__(
|
37
|
+
self,
|
38
|
+
phone: str,
|
39
|
+
uri: str = Constants.WEBSOCKET_URI.value,
|
40
|
+
work_dir: str = ".",
|
41
|
+
logger: logging.Logger | None = None,
|
42
|
+
) -> None:
|
43
|
+
self.uri: str = uri
|
44
|
+
self.is_connected: bool = False
|
45
|
+
self.phone: str = phone
|
46
|
+
self.chats: list[Chat] = []
|
47
|
+
self.dialogs: list[Dialog] = []
|
48
|
+
self.channels: list[Channel] = []
|
49
|
+
self._users: dict[int, User] = {}
|
50
|
+
if not self._check_phone():
|
51
|
+
raise InvalidPhoneError(self.phone)
|
52
|
+
self._work_dir: str = work_dir
|
53
|
+
self._database_path: Path = Path(work_dir) / "session.db"
|
54
|
+
self._database_path.parent.mkdir(parents=True, exist_ok=True)
|
55
|
+
self._database_path.touch(exist_ok=True)
|
56
|
+
self._database = Database(self._work_dir)
|
57
|
+
self._ws: websockets.ClientConnection | None = None
|
58
|
+
self._seq: int = 0
|
59
|
+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
60
|
+
self._recv_task: asyncio.Task[Any] | None = None
|
61
|
+
self._incoming: asyncio.Queue[dict[str, Any]] | None = None
|
62
|
+
self._device_id = self._database.get_device_id()
|
63
|
+
self._token = self._database.get_auth_token()
|
64
|
+
self.user_agent = Constants.DEFAULT_USER_AGENT.value
|
65
|
+
self._on_message_handler: Callable[[Message], Any] | None = None
|
66
|
+
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
67
|
+
self._background_tasks: set[asyncio.Task[Any]] = set()
|
68
|
+
self.logger = logger or logging.getLogger(f"{__name__}.MaxClient")
|
69
|
+
self._setup_logger()
|
70
|
+
|
71
|
+
self.logger.debug("Initialized MaxClient uri=%s work_dir=%s", self.uri, self._work_dir)
|
72
|
+
|
73
|
+
def _setup_logger(self) -> None:
|
74
|
+
self.logger.setLevel(logging.INFO)
|
75
|
+
|
76
|
+
if not logger.handlers:
|
77
|
+
handler = logging.StreamHandler()
|
78
|
+
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
79
|
+
handler.setFormatter(formatter)
|
80
|
+
logger.addHandler(handler)
|
81
|
+
|
82
|
+
@property
|
83
|
+
def ws(self) -> websockets.ClientConnection:
|
84
|
+
if self._ws is None or not self.is_connected:
|
85
|
+
self.logger.critical("WebSocket not connected when access attempted")
|
86
|
+
raise WebSocketNotConnectedError
|
87
|
+
return self._ws
|
88
|
+
|
89
|
+
def on_message(
|
90
|
+
self, handler: Callable[[Message], Any | Awaitable[Any]]
|
91
|
+
) -> Callable[[Message], Any | Awaitable[Any]]:
|
92
|
+
"""
|
93
|
+
Устанавливает обработчик входящих сообщений.
|
94
|
+
|
95
|
+
Args:
|
96
|
+
handler: Функция или coroutine, принимающая объект Message.
|
97
|
+
|
98
|
+
Returns:
|
99
|
+
Установленный обработчик.
|
100
|
+
"""
|
101
|
+
self._on_message_handler = handler
|
102
|
+
self.logger.debug("on_message handler set: %r", handler)
|
103
|
+
return handler
|
104
|
+
|
105
|
+
def on_start(
|
106
|
+
self, handler: Callable[[], Any | Awaitable[Any]]
|
107
|
+
) -> Callable[[], Any | Awaitable[Any]]:
|
108
|
+
"""
|
109
|
+
Устанавливает обработчик, вызываемый при старте клиента.
|
110
|
+
|
111
|
+
Args:
|
112
|
+
handler: Функция или coroutine без аргументов.
|
113
|
+
|
114
|
+
Returns:
|
115
|
+
Установленный обработчик.
|
116
|
+
"""
|
117
|
+
self._on_start_handler = handler
|
118
|
+
self.logger.debug("on_start handler set: %r", handler)
|
119
|
+
return handler
|
120
|
+
|
121
|
+
def add_message_handler(
|
122
|
+
self, handler: Callable[[Message], Any | Awaitable[Any]]
|
123
|
+
) -> Callable[[Message], Any | Awaitable[Any]]:
|
124
|
+
self.logger.debug("add_message_handler (alias) used")
|
125
|
+
self._on_message_handler = handler
|
126
|
+
return handler
|
127
|
+
|
128
|
+
def add_on_start_handler(
|
129
|
+
self, handler: Callable[[], Any | Awaitable[Any]]
|
130
|
+
) -> Callable[[], Any | Awaitable[Any]]:
|
131
|
+
self.logger.debug("add_on_start_handler (alias) used")
|
132
|
+
self._on_start_handler = handler
|
133
|
+
return handler
|
134
|
+
|
135
|
+
def _check_phone(self) -> bool:
|
136
|
+
return bool(re.match(Constants.PHONE_REGEX.value, self.phone))
|
137
|
+
|
138
|
+
def _make_message(self, opcode: int, payload: dict[str, Any], cmd: int = 0) -> dict[str, Any]:
|
139
|
+
self._seq += 1
|
140
|
+
msg = {
|
141
|
+
"ver": 11,
|
142
|
+
"cmd": cmd,
|
143
|
+
"seq": self._seq,
|
144
|
+
"opcode": opcode,
|
145
|
+
"payload": payload,
|
146
|
+
}
|
147
|
+
self.logger.debug("make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq)
|
148
|
+
return msg
|
149
|
+
|
150
|
+
async def _send_interactive_ping(self) -> None:
|
151
|
+
while self.is_connected:
|
152
|
+
try:
|
153
|
+
await self._send_and_wait(
|
154
|
+
opcode=1,
|
155
|
+
payload={"interactive": True},
|
156
|
+
cmd=0,
|
157
|
+
)
|
158
|
+
self.logger.debug("Interactive ping sent successfully")
|
159
|
+
except Exception:
|
160
|
+
self.logger.warning("Interactive ping failed", exc_info=True)
|
161
|
+
await asyncio.sleep(30)
|
162
|
+
|
163
|
+
async def _connect(self, user_agent: dict[str, Any]) -> dict[str, Any]:
|
164
|
+
try:
|
165
|
+
self.logger.info("Connecting to WebSocket %s", self.uri)
|
166
|
+
self._ws = await websockets.connect(self.uri)
|
167
|
+
self.is_connected = True
|
168
|
+
self._incoming = asyncio.Queue()
|
169
|
+
self._pending = {}
|
170
|
+
self._recv_task = asyncio.create_task(self._recv_loop())
|
171
|
+
self.logger.info("WebSocket connected, starting handshake")
|
172
|
+
return await self._handshake(user_agent)
|
173
|
+
except Exception as e:
|
174
|
+
self.logger.error("Failed to connect: %s", e, exc_info=True)
|
175
|
+
raise ConnectionError(f"Failed to connect: {e}")
|
176
|
+
|
177
|
+
async def _handshake(self, user_agent: dict[str, Any]) -> dict[str, Any]:
|
178
|
+
try:
|
179
|
+
self.logger.debug("Sending handshake with user_agent keys=%s", list(user_agent.keys()))
|
180
|
+
resp = await self._send_and_wait(
|
181
|
+
opcode=Opcode.HANDSHAKE,
|
182
|
+
payload={"deviceId": str(self._device_id), "userAgent": user_agent},
|
183
|
+
)
|
184
|
+
self.logger.info("Handshake completed")
|
185
|
+
return resp
|
186
|
+
except Exception as e:
|
187
|
+
self.logger.error("Handshake failed: %s", e, exc_info=True)
|
188
|
+
raise ConnectionError(f"Handshake failed: {e}")
|
189
|
+
|
190
|
+
async def _request_code(self, phone: str, language: str = "ru") -> dict[str, int | str]:
|
191
|
+
try:
|
192
|
+
self.logger.info("Requesting auth code")
|
193
|
+
payload = {
|
194
|
+
"phone": phone,
|
195
|
+
"type": AuthType.START_AUTH.value,
|
196
|
+
"language": language,
|
197
|
+
}
|
198
|
+
data = await self._send_and_wait(opcode=Opcode.REQUEST_CODE, payload=payload)
|
199
|
+
self.logger.debug(
|
200
|
+
"Code request response opcode=%s seq=%s", data.get("opcode"), data.get("seq")
|
201
|
+
)
|
202
|
+
return data.get("payload")
|
203
|
+
except Exception:
|
204
|
+
self.logger.error("Request code failed", exc_info=True)
|
205
|
+
raise RuntimeError("Request code failed")
|
206
|
+
|
207
|
+
async def _send_code(self, code: str, token: str) -> dict[str, Any]:
|
208
|
+
try:
|
209
|
+
self.logger.info("Sending verification code")
|
210
|
+
payload = {
|
211
|
+
"token": token,
|
212
|
+
"verifyCode": code,
|
213
|
+
"authTokenType": AuthType.CHECK_CODE.value,
|
214
|
+
}
|
215
|
+
data = await self._send_and_wait(opcode=Opcode.SEND_CODE, payload=payload)
|
216
|
+
self.logger.debug(
|
217
|
+
"Send code response opcode=%s seq=%s", data.get("opcode"), data.get("seq")
|
218
|
+
)
|
219
|
+
return data.get("payload")
|
220
|
+
except Exception:
|
221
|
+
self.logger.error("Send code failed", exc_info=True)
|
222
|
+
raise RuntimeError("Send code failed")
|
223
|
+
|
224
|
+
async def _recv_loop(self) -> None:
|
225
|
+
if self._ws is None:
|
226
|
+
self.logger.warning("Recv loop started without websocket instance")
|
227
|
+
return
|
228
|
+
|
229
|
+
self.logger.debug("Receive loop started")
|
230
|
+
while True:
|
231
|
+
try:
|
232
|
+
raw = await self._ws.recv()
|
233
|
+
try:
|
234
|
+
data = json.loads(raw)
|
235
|
+
except Exception:
|
236
|
+
self.logger.warning("JSON parse error", exc_info=True)
|
237
|
+
continue
|
238
|
+
|
239
|
+
seq = data.get("seq")
|
240
|
+
fut = self._pending.get(seq) if isinstance(seq, int) else None
|
241
|
+
|
242
|
+
if fut and not fut.done():
|
243
|
+
fut.set_result(data)
|
244
|
+
self.logger.debug("Matched response for pending seq=%s", seq)
|
245
|
+
else:
|
246
|
+
if self._incoming is not None:
|
247
|
+
try:
|
248
|
+
self._incoming.put_nowait(data)
|
249
|
+
except asyncio.QueueFull:
|
250
|
+
self.logger.warning(
|
251
|
+
"Incoming queue full; dropping message seq=%s", data.get("seq")
|
252
|
+
)
|
253
|
+
|
254
|
+
if data.get("opcode") == Opcode.NEW_MESSAGE and self._on_message_handler:
|
255
|
+
try:
|
256
|
+
payload = data.get("payload", {})
|
257
|
+
msg = Message.from_dict(payload.get("message"))
|
258
|
+
if msg:
|
259
|
+
result = self._on_message_handler(msg)
|
260
|
+
if asyncio.iscoroutine(result):
|
261
|
+
task = asyncio.create_task(result)
|
262
|
+
self._background_tasks.add(task)
|
263
|
+
task.add_done_callback(
|
264
|
+
lambda t: self._background_tasks.discard(t)
|
265
|
+
or self._log_task_exception(t)
|
266
|
+
)
|
267
|
+
except Exception:
|
268
|
+
self.logger.exception("Error in on_message_handler")
|
269
|
+
|
270
|
+
except websockets.exceptions.ConnectionClosed:
|
271
|
+
self.logger.info("WebSocket connection closed; exiting recv loop")
|
272
|
+
break
|
273
|
+
except Exception:
|
274
|
+
self.logger.exception("Error in recv_loop; backing off briefly")
|
275
|
+
await asyncio.sleep(0.5)
|
276
|
+
|
277
|
+
def _log_task_exception(self, task: asyncio.Task[Any]) -> None:
|
278
|
+
try:
|
279
|
+
exc = task.exception()
|
280
|
+
if exc:
|
281
|
+
self.logger.exception("Background task exception: %s", exc)
|
282
|
+
except Exception:
|
283
|
+
# ignore inspection failures
|
284
|
+
pass
|
285
|
+
|
286
|
+
async def _send_and_wait(
|
287
|
+
self,
|
288
|
+
opcode: int,
|
289
|
+
payload: dict[str, Any],
|
290
|
+
cmd: int = 0,
|
291
|
+
timeout: float = Constants.DEFAULT_TIMEOUT.value,
|
292
|
+
) -> dict[str, Any]:
|
293
|
+
# Проверка соединения — с логированием критичности
|
294
|
+
ws = self.ws # вызовет исключение и CRITICAL-лог, если не подключены
|
295
|
+
|
296
|
+
msg = self._make_message(opcode, payload, cmd)
|
297
|
+
loop = asyncio.get_running_loop()
|
298
|
+
fut: asyncio.Future[dict[str, Any]] = loop.create_future()
|
299
|
+
self._pending[msg["seq"]] = fut
|
300
|
+
|
301
|
+
try:
|
302
|
+
self.logger.debug("Sending frame opcode=%s cmd=%s seq=%s", opcode, cmd, msg["seq"])
|
303
|
+
await ws.send(json.dumps(msg))
|
304
|
+
data = await asyncio.wait_for(fut, timeout=timeout)
|
305
|
+
self.logger.debug(
|
306
|
+
"Received frame for seq=%s opcode=%s", data.get("seq"), data.get("opcode")
|
307
|
+
)
|
308
|
+
return data
|
309
|
+
except Exception:
|
310
|
+
self.logger.exception("Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"])
|
311
|
+
raise RuntimeError("Send and wait failed")
|
312
|
+
finally:
|
313
|
+
self._pending.pop(msg["seq"], None)
|
314
|
+
|
315
|
+
async def _sync(self) -> None:
|
316
|
+
try:
|
317
|
+
self.logger.info("Starting initial sync")
|
318
|
+
payload = {
|
319
|
+
"interactive": True,
|
320
|
+
"token": self._token,
|
321
|
+
"chatsSync": 0,
|
322
|
+
"contactsSync": 0,
|
323
|
+
"presenceSync": 0,
|
324
|
+
"draftsSync": 0,
|
325
|
+
"chatsCount": 40,
|
326
|
+
}
|
327
|
+
data = await self._send_and_wait(opcode=19, payload=payload)
|
328
|
+
if error := data.get("payload", {}).get("error"):
|
329
|
+
self.logger.error("Sync error: %s", error)
|
330
|
+
return
|
331
|
+
|
332
|
+
for raw_chat in data.get("payload", {}).get("chats", []):
|
333
|
+
try:
|
334
|
+
if raw_chat.get("type") == ChatType.DIALOG.value:
|
335
|
+
self.dialogs.append(Dialog.from_dict(raw_chat))
|
336
|
+
elif raw_chat.get("type") == ChatType.CHAT.value:
|
337
|
+
self.chats.append(Chat.from_dict(raw_chat))
|
338
|
+
elif raw_chat.get("type") == ChatType.CHANNEL.value:
|
339
|
+
self.channels.append(Channel.from_dict(raw_chat))
|
340
|
+
except Exception:
|
341
|
+
self.logger.exception("Error parsing chat entry")
|
342
|
+
self.logger.info(
|
343
|
+
"Sync completed: dialogs=%d chats=%d channels=%d",
|
344
|
+
len(self.dialogs),
|
345
|
+
len(self.chats),
|
346
|
+
len(self.channels),
|
347
|
+
)
|
348
|
+
except Exception:
|
349
|
+
self.logger.exception("Sync failed")
|
350
|
+
|
351
|
+
async def send_message(self, text: str, chat_id: int, notify: bool) -> Message | None:
|
352
|
+
"""
|
353
|
+
Отправляет сообщение в чат.
|
354
|
+
"""
|
355
|
+
try:
|
356
|
+
self.logger.info("Sending message to chat_id=%s notify=%s", chat_id, notify)
|
357
|
+
payload = {
|
358
|
+
"chatId": chat_id,
|
359
|
+
"message": {
|
360
|
+
"text": text,
|
361
|
+
"cid": int(time.time() * 1000),
|
362
|
+
"elements": [],
|
363
|
+
"attaches": [],
|
364
|
+
},
|
365
|
+
"notify": notify,
|
366
|
+
}
|
367
|
+
data = await self._send_and_wait(opcode=Opcode.SEND_MESSAGE, payload=payload)
|
368
|
+
if error := data.get("payload", {}).get("error"):
|
369
|
+
self.logger.error("Send message error: %s", error)
|
370
|
+
msg = Message.from_dict(data["payload"]["message"]) if data.get("payload") else None
|
371
|
+
self.logger.debug("send_message result: %r", msg)
|
372
|
+
return msg
|
373
|
+
except Exception:
|
374
|
+
self.logger.exception("Send message failed")
|
375
|
+
return None
|
376
|
+
|
377
|
+
async def edit_message(self, chat_id: int, message_id: int, text: str) -> Message | None:
|
378
|
+
"""
|
379
|
+
Редактирует сообщение.
|
380
|
+
"""
|
381
|
+
try:
|
382
|
+
self.logger.info("Editing message chat_id=%s message_id=%s", chat_id, message_id)
|
383
|
+
payload = {
|
384
|
+
"chatId": chat_id,
|
385
|
+
"messageId": message_id,
|
386
|
+
"text": text,
|
387
|
+
"elements": [],
|
388
|
+
"attaches": [],
|
389
|
+
}
|
390
|
+
data = await self._send_and_wait(opcode=Opcode.EDIT_MESSAGE, payload=payload)
|
391
|
+
if error := data.get("payload", {}).get("error"):
|
392
|
+
self.logger.error("Edit message error: %s", error)
|
393
|
+
msg = Message.from_dict(data["payload"]["message"]) if data.get("payload") else None
|
394
|
+
self.logger.debug("edit_message result: %r", msg)
|
395
|
+
return msg
|
396
|
+
except Exception:
|
397
|
+
self.logger.exception("Edit message failed")
|
398
|
+
return None
|
399
|
+
|
400
|
+
async def delete_message(self, chat_id: int, message_ids: list[int], for_me: bool) -> bool:
|
401
|
+
"""
|
402
|
+
Удаляет сообщения.
|
403
|
+
"""
|
404
|
+
try:
|
405
|
+
self.logger.info(
|
406
|
+
"Deleting messages chat_id=%s ids=%s for_me=%s", chat_id, message_ids, for_me
|
407
|
+
)
|
408
|
+
payload = {"chatId": chat_id, "messageIds": message_ids, "forMe": for_me}
|
409
|
+
data = await self._send_and_wait(opcode=Opcode.DELETE_MESSAGE, payload=payload)
|
410
|
+
if error := data.get("payload", {}).get("error"):
|
411
|
+
self.logger.error("Delete message error: %s", error)
|
412
|
+
return False
|
413
|
+
self.logger.debug("delete_message success")
|
414
|
+
return True
|
415
|
+
except Exception:
|
416
|
+
self.logger.exception("Delete message failed")
|
417
|
+
return False
|
418
|
+
|
419
|
+
async def close(self) -> None:
|
420
|
+
try:
|
421
|
+
self.logger.info("Closing client")
|
422
|
+
if self._recv_task:
|
423
|
+
self._recv_task.cancel()
|
424
|
+
try:
|
425
|
+
await self._recv_task
|
426
|
+
except asyncio.CancelledError:
|
427
|
+
self.logger.debug("recv_task cancelled")
|
428
|
+
if self._ws:
|
429
|
+
await self._ws.close()
|
430
|
+
self.is_connected = False
|
431
|
+
self.logger.info("Client closed")
|
432
|
+
except Exception:
|
433
|
+
self.logger.exception("Error closing client")
|
434
|
+
|
435
|
+
def get_cached_user(self, user_id: int) -> User | None:
|
436
|
+
"""
|
437
|
+
Получает юзера из кеша по его ID
|
438
|
+
|
439
|
+
Args:
|
440
|
+
user_id (int): ID пользователя.
|
441
|
+
|
442
|
+
Returns:
|
443
|
+
User | None: Объект User или None при ошибке.
|
444
|
+
"""
|
445
|
+
user = self._users.get(user_id)
|
446
|
+
self.logger.debug("get_cached_user id=%s hit=%s", user_id, bool(user))
|
447
|
+
return user
|
448
|
+
|
449
|
+
async def get_users(self, user_ids: list[int]) -> list[User]:
|
450
|
+
"""
|
451
|
+
Получает информацию о пользователях по их ID (с кешем).
|
452
|
+
"""
|
453
|
+
self.logger.debug("get_users ids=%s", user_ids)
|
454
|
+
cached = {uid: self._users[uid] for uid in user_ids if uid in self._users}
|
455
|
+
missing_ids = [uid for uid in user_ids if uid not in self._users]
|
456
|
+
|
457
|
+
if missing_ids:
|
458
|
+
self.logger.debug("Fetching missing users: %s", missing_ids)
|
459
|
+
fetched_users = await self.fetch_users(missing_ids)
|
460
|
+
if fetched_users:
|
461
|
+
for user in fetched_users:
|
462
|
+
self._users[user.id] = user
|
463
|
+
cached[user.id] = user
|
464
|
+
|
465
|
+
ordered = [cached[uid] for uid in user_ids if uid in cached]
|
466
|
+
self.logger.debug("get_users result_count=%d", len(ordered))
|
467
|
+
return ordered
|
468
|
+
|
469
|
+
async def get_user(self, user_id: int) -> User | None:
|
470
|
+
"""
|
471
|
+
Получает информацию о пользователе по его ID (с кешем).
|
472
|
+
"""
|
473
|
+
self.logger.debug("get_user id=%s", user_id)
|
474
|
+
if user_id in self._users:
|
475
|
+
return self._users[user_id]
|
476
|
+
|
477
|
+
users = await self.fetch_users([user_id])
|
478
|
+
if users:
|
479
|
+
self._users[user_id] = users[0]
|
480
|
+
return users[0]
|
481
|
+
return None
|
482
|
+
|
483
|
+
async def fetch_users(self, user_ids: list[int]) -> None | list[User]:
|
484
|
+
"""
|
485
|
+
Получает информацию о пользователях по их ID.
|
486
|
+
"""
|
487
|
+
try:
|
488
|
+
self.logger.info("Fetching users count=%d", len(user_ids))
|
489
|
+
payload = {"contactIds": user_ids}
|
490
|
+
|
491
|
+
data = await self._send_and_wait(opcode=Opcode.GET_CONTACTS_INFO, payload=payload)
|
492
|
+
if error := data.get("payload", {}).get("error"):
|
493
|
+
self.logger.error("Fetch users error: %s", error)
|
494
|
+
return None
|
495
|
+
|
496
|
+
users = [User.from_dict(u) for u in data["payload"].get("contacts", [])]
|
497
|
+
for user in users:
|
498
|
+
self._users[user.id] = user
|
499
|
+
|
500
|
+
self.logger.debug("Fetched users: %d", len(users))
|
501
|
+
return users
|
502
|
+
except Exception:
|
503
|
+
self.logger.exception("Fetch users failed")
|
504
|
+
return []
|
505
|
+
|
506
|
+
async def fetch_history(
|
507
|
+
self,
|
508
|
+
chat_id: int,
|
509
|
+
from_time: int | None = None,
|
510
|
+
forward: int = 0,
|
511
|
+
backward: int = 200,
|
512
|
+
) -> list[Message] | None:
|
513
|
+
"""
|
514
|
+
Получает историю сообщений чата.
|
515
|
+
"""
|
516
|
+
if from_time is None:
|
517
|
+
from_time = int(time.time() * 1000)
|
518
|
+
|
519
|
+
try:
|
520
|
+
self.logger.info(
|
521
|
+
"Fetching history chat_id=%s from=%s forward=%s backward=%s",
|
522
|
+
chat_id,
|
523
|
+
from_time,
|
524
|
+
forward,
|
525
|
+
backward,
|
526
|
+
)
|
527
|
+
payload = {
|
528
|
+
"chatId": chat_id,
|
529
|
+
"from": from_time,
|
530
|
+
"forward": forward,
|
531
|
+
"backward": backward,
|
532
|
+
"getMessages": True,
|
533
|
+
}
|
534
|
+
|
535
|
+
data = await self._send_and_wait(opcode=Opcode.FETCH_HISTORY, payload=payload)
|
536
|
+
if error := data.get("payload", {}).get("error"):
|
537
|
+
self.logger.error("Fetch history error: %s", error)
|
538
|
+
return None
|
539
|
+
messages = [Message.from_dict(msg) for msg in data["payload"].get("messages", [])]
|
540
|
+
self.logger.debug("History fetched: %d messages", len(messages))
|
541
|
+
return messages
|
542
|
+
except Exception:
|
543
|
+
self.logger.exception("Fetch history failed")
|
544
|
+
return None
|
545
|
+
|
546
|
+
async def _login(self) -> None:
|
547
|
+
self.logger.info("Starting login flow")
|
548
|
+
request_code_payload = await self._request_code(self.phone)
|
549
|
+
temp_token = request_code_payload.get("token")
|
550
|
+
if not temp_token or not isinstance(temp_token, str):
|
551
|
+
self.logger.critical("Failed to request code: token missing")
|
552
|
+
raise ValueError("Failed to request code")
|
553
|
+
|
554
|
+
code = await asyncio.to_thread(input, "Введите код: ")
|
555
|
+
if len(code) != 6 or not code.isdigit():
|
556
|
+
self.logger.error("Invalid code format entered")
|
557
|
+
raise ValueError("Invalid code format")
|
558
|
+
|
559
|
+
login_resp = await self._send_code(code, temp_token)
|
560
|
+
token: str | None = login_resp.get("tokenAttrs", {}).get("LOGIN", {}).get("token")
|
561
|
+
if not token:
|
562
|
+
self.logger.critical("Failed to login, token not received")
|
563
|
+
raise ValueError("Failed to login, token not received")
|
564
|
+
|
565
|
+
self._token = token
|
566
|
+
self._database.update_auth_token(self._device_id, self._token)
|
567
|
+
self.logger.info("Login successful, token saved to database")
|
568
|
+
|
569
|
+
async def start(self) -> None:
|
570
|
+
"""
|
571
|
+
Запускает клиент, подключается к WebSocket, авторизует
|
572
|
+
пользователя (если нужно) и запускает фоновый цикл.
|
573
|
+
"""
|
574
|
+
try:
|
575
|
+
self.logger.info("Client starting")
|
576
|
+
await self._connect(self.user_agent)
|
577
|
+
if self._token is None:
|
578
|
+
await self._login()
|
579
|
+
else:
|
580
|
+
await self._sync()
|
581
|
+
|
582
|
+
if self._on_start_handler:
|
583
|
+
self.logger.debug("Calling on_start handler")
|
584
|
+
result = self._on_start_handler()
|
585
|
+
if asyncio.iscoroutine(result):
|
586
|
+
await result
|
587
|
+
|
588
|
+
if self._ws:
|
589
|
+
ping_task = asyncio.create_task(self._send_interactive_ping())
|
590
|
+
self._background_tasks.add(ping_task)
|
591
|
+
ping_task.add_done_callback(
|
592
|
+
lambda t: self._background_tasks.discard(t) or self._log_task_exception(t)
|
593
|
+
)
|
594
|
+
|
595
|
+
try:
|
596
|
+
await self._ws.wait_closed()
|
597
|
+
except asyncio.CancelledError:
|
598
|
+
self.logger.debug("wait_closed cancelled")
|
599
|
+
except Exception:
|
600
|
+
self.logger.exception("Client start failed")
|
pymax/crud.py
ADDED
@@ -0,0 +1,99 @@
|
|
1
|
+
from uuid import UUID
|
2
|
+
|
3
|
+
from sqlalchemy.engine.base import Engine
|
4
|
+
from sqlmodel import Session, SQLModel, create_engine, select
|
5
|
+
|
6
|
+
from .models import Auth
|
7
|
+
from .static import DeviceType
|
8
|
+
|
9
|
+
|
10
|
+
class Database:
|
11
|
+
def __init__(self, workdir: str) -> None:
|
12
|
+
self.workdir = workdir
|
13
|
+
self.engine = self.get_engine(workdir)
|
14
|
+
self.create_all()
|
15
|
+
self._ensure_single_auth()
|
16
|
+
|
17
|
+
self.workdir = workdir
|
18
|
+
self.engine = self.get_engine(workdir)
|
19
|
+
self.create_all()
|
20
|
+
self._ensure_single_auth()
|
21
|
+
|
22
|
+
def create_all(self) -> None:
|
23
|
+
SQLModel.metadata.create_all(self.engine)
|
24
|
+
|
25
|
+
def get_engine(self, workdir: str) -> Engine:
|
26
|
+
return create_engine(f"sqlite:///{workdir}/session.db")
|
27
|
+
|
28
|
+
def get_session(self) -> Session:
|
29
|
+
return Session(bind=self.engine)
|
30
|
+
|
31
|
+
def get_auth_token(self) -> str | None:
|
32
|
+
with self.get_session() as session:
|
33
|
+
return session.exec(select(Auth.token)).first()
|
34
|
+
|
35
|
+
def get_device_id(self) -> UUID:
|
36
|
+
with self.get_session() as session:
|
37
|
+
device_id = session.exec(select(Auth.device_id)).first()
|
38
|
+
if device_id is None:
|
39
|
+
auth = Auth()
|
40
|
+
session.add(auth)
|
41
|
+
session.commit()
|
42
|
+
session.refresh(auth)
|
43
|
+
return auth.device_id
|
44
|
+
return device_id
|
45
|
+
|
46
|
+
def insert_auth(self, auth: Auth) -> Auth:
|
47
|
+
with self.get_session() as session:
|
48
|
+
session.add(auth)
|
49
|
+
session.commit()
|
50
|
+
session.refresh(auth)
|
51
|
+
return auth
|
52
|
+
|
53
|
+
def update_auth_token(self, device_id: UUID, token: str) -> None:
|
54
|
+
with self.get_session() as session:
|
55
|
+
auth = session.exec(select(Auth).where(Auth.device_id == device_id)).first()
|
56
|
+
if auth:
|
57
|
+
auth.token = token
|
58
|
+
session.add(auth)
|
59
|
+
session.commit()
|
60
|
+
session.refresh(auth)
|
61
|
+
return
|
62
|
+
|
63
|
+
existing = session.exec(select(Auth)).first()
|
64
|
+
if existing:
|
65
|
+
existing.device_id = device_id
|
66
|
+
existing.token = token
|
67
|
+
session.add(existing)
|
68
|
+
session.commit()
|
69
|
+
session.refresh(existing)
|
70
|
+
return
|
71
|
+
|
72
|
+
new_auth = Auth(device_id=device_id, token=token)
|
73
|
+
session.add(new_auth)
|
74
|
+
session.commit()
|
75
|
+
session.refresh(new_auth)
|
76
|
+
|
77
|
+
def update(self, auth: Auth) -> Auth:
|
78
|
+
with self.get_session() as session:
|
79
|
+
session.add(auth)
|
80
|
+
session.commit()
|
81
|
+
session.refresh(auth)
|
82
|
+
return auth
|
83
|
+
|
84
|
+
def _ensure_single_auth(self) -> None:
|
85
|
+
with self.get_session() as session:
|
86
|
+
rows = session.exec(select(Auth)).all()
|
87
|
+
if not rows:
|
88
|
+
# Create default Auth with device type from enum
|
89
|
+
auth = Auth(device_type=DeviceType.WEB.value)
|
90
|
+
session.add(auth)
|
91
|
+
session.commit()
|
92
|
+
session.refresh(auth)
|
93
|
+
return
|
94
|
+
|
95
|
+
if len(rows) > 1:
|
96
|
+
keeper = rows[0]
|
97
|
+
for extra in rows[1:]:
|
98
|
+
session.delete(extra)
|
99
|
+
session.commit()
|
pymax/exceptions.py
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
class InvalidPhoneError(Exception):
|
2
|
+
"""
|
3
|
+
Исключение, вызываемое при неверном формате номера телефона.
|
4
|
+
|
5
|
+
Args:
|
6
|
+
phone (str): Некорректный номер телефона.
|
7
|
+
"""
|
8
|
+
|
9
|
+
def __init__(self, phone: str) -> None:
|
10
|
+
super().__init__(f"Invalid phone number format: {phone}")
|
11
|
+
|
12
|
+
|
13
|
+
class WebSocketNotConnectedError(Exception):
|
14
|
+
"""
|
15
|
+
Исключение, вызываемое при попытке обращения к WebSocket,
|
16
|
+
если соединение не установлено.
|
17
|
+
"""
|
18
|
+
|
19
|
+
def __init__(self) -> None:
|
20
|
+
super().__init__("WebSocket is not connected")
|
pymax/models.py
ADDED
pymax/static.py
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
from enum import Enum, IntEnum
|
2
|
+
|
3
|
+
|
4
|
+
class Opcode(IntEnum):
|
5
|
+
PING = 1
|
6
|
+
STATS = 5
|
7
|
+
HANDSHAKE = 6
|
8
|
+
PROFILE = 16
|
9
|
+
REQUEST_CODE = 17
|
10
|
+
SEND_CODE = 18
|
11
|
+
SYNC = 19
|
12
|
+
UNKNOWN_26 = 26
|
13
|
+
SYNC_STICKERS_EMOJIS = 27
|
14
|
+
GET_EMOJIS_BY_ID = 28
|
15
|
+
GET_CONTACTS_INFO = 32
|
16
|
+
GET_LAST_SEEN = 35
|
17
|
+
GET_CHATS_DATA = 48
|
18
|
+
FETCH_HISTORY = 49
|
19
|
+
|
20
|
+
GET_HISTORY = 79
|
21
|
+
|
22
|
+
SEND_MESSAGE = 64
|
23
|
+
EDIT_MESSAGE = 67
|
24
|
+
DELETE_MESSAGE = 68
|
25
|
+
|
26
|
+
NEW_MESSAGE = 128
|
27
|
+
|
28
|
+
|
29
|
+
class ChatType(str, Enum):
|
30
|
+
DIALOG = "DIALOG"
|
31
|
+
CHAT = "CHAT"
|
32
|
+
CHANNEL = "CHANNEL"
|
33
|
+
|
34
|
+
|
35
|
+
class MessageType(str, Enum):
|
36
|
+
TEXT = "TEXT"
|
37
|
+
SYSTEM = "SYSTEM"
|
38
|
+
SERVICE = "SERVICE"
|
39
|
+
|
40
|
+
|
41
|
+
class MessageStatus(str, Enum):
|
42
|
+
SENT = "SENT"
|
43
|
+
DELIVERED = "DELIVERED"
|
44
|
+
READ = "READ"
|
45
|
+
ERROR = "ERROR"
|
46
|
+
|
47
|
+
|
48
|
+
class ElementType(str, Enum):
|
49
|
+
TEXT = "text"
|
50
|
+
MENTION = "mention"
|
51
|
+
LINK = "link"
|
52
|
+
EMOJI = "emoji"
|
53
|
+
|
54
|
+
|
55
|
+
class AuthType(str, Enum):
|
56
|
+
START_AUTH = "START_AUTH"
|
57
|
+
CHECK_CODE = "CHECK_CODE"
|
58
|
+
|
59
|
+
|
60
|
+
class AccessType(str, Enum):
|
61
|
+
PUBLIC = "PUBLIC"
|
62
|
+
PRIVATE = "PRIVATE"
|
63
|
+
SECRET = "SECRET"
|
64
|
+
|
65
|
+
|
66
|
+
class DeviceType(str, Enum):
|
67
|
+
WEB = "WEB"
|
68
|
+
ANDROID = "ANDROID"
|
69
|
+
IOS = "IOS"
|
70
|
+
|
71
|
+
|
72
|
+
class Constants(Enum):
|
73
|
+
PHONE_REGEX = r"^\+?\d{10,15}$"
|
74
|
+
WEBSOCKET_URI = "wss://ws-api.oneme.ru/websocket"
|
75
|
+
DEFAULT_TIMEOUT = 10.0
|
76
|
+
DEFAULT_USER_AGENT = {
|
77
|
+
"deviceType": "WEB",
|
78
|
+
"locale": "ru",
|
79
|
+
"deviceLocale": "ru",
|
80
|
+
"osVersion": "Linux",
|
81
|
+
"deviceName": "Chrome",
|
82
|
+
"headerUserAgent": "Mozilla/5.0 ...",
|
83
|
+
"appVersion": "25.8.5",
|
84
|
+
"screen": "1080x1920 1.0x",
|
85
|
+
"timezone": "Europe/Moscow",
|
86
|
+
}
|
pymax/types.py
ADDED
@@ -0,0 +1,327 @@
|
|
1
|
+
from typing import Any
|
2
|
+
|
3
|
+
from .static import AccessType, ChatType, ElementType, MessageStatus, MessageType
|
4
|
+
|
5
|
+
|
6
|
+
class Element:
|
7
|
+
def __init__(self, type: ElementType | str, length: int, from_: int | None = None) -> None:
|
8
|
+
self.type = type
|
9
|
+
self.length = length
|
10
|
+
self.from_ = from_
|
11
|
+
|
12
|
+
@classmethod
|
13
|
+
def from_dict(cls, data: dict[Any, Any]) -> "Element":
|
14
|
+
return cls(type=data["type"], length=data["length"], from_=data.get("from"))
|
15
|
+
|
16
|
+
def __repr__(self) -> str:
|
17
|
+
return f"Element(type={self.type!r}, length={self.length!r}, from_={self.from_!r})"
|
18
|
+
|
19
|
+
def __str__(self) -> str:
|
20
|
+
return f"{self.type}({self.length})"
|
21
|
+
|
22
|
+
|
23
|
+
class Message:
|
24
|
+
def __init__(
|
25
|
+
self,
|
26
|
+
sender: int | None,
|
27
|
+
elements: list[Element] | None,
|
28
|
+
reaction_info: dict[str, Any] | None,
|
29
|
+
options: int | None,
|
30
|
+
id: int,
|
31
|
+
time: int,
|
32
|
+
text: str,
|
33
|
+
status: MessageStatus | str | None,
|
34
|
+
type: MessageType | str,
|
35
|
+
attaches: list[Any],
|
36
|
+
) -> None:
|
37
|
+
self.sender = sender
|
38
|
+
self.elements = elements
|
39
|
+
self.options = options
|
40
|
+
self.id = id
|
41
|
+
self.time = time
|
42
|
+
self.text = text
|
43
|
+
self.type = type
|
44
|
+
self.attaches = attaches
|
45
|
+
self.status = status
|
46
|
+
self.reactionInfo = reaction_info
|
47
|
+
|
48
|
+
@classmethod
|
49
|
+
def from_dict(cls, data: dict[Any, Any]) -> "Message":
|
50
|
+
return cls(
|
51
|
+
sender=data.get("sender"),
|
52
|
+
elements=[Element.from_dict(e) for e in data.get("elements", [])],
|
53
|
+
options=data.get("options"),
|
54
|
+
id=data["id"],
|
55
|
+
time=data["time"],
|
56
|
+
text=data["text"],
|
57
|
+
type=data["type"],
|
58
|
+
attaches=data.get("attaches", []),
|
59
|
+
status=data.get("status"),
|
60
|
+
reaction_info=data.get("reactionInfo"),
|
61
|
+
)
|
62
|
+
|
63
|
+
def __repr__(self) -> str:
|
64
|
+
return (
|
65
|
+
f"Message(id={self.id!r}, sender={self.sender!r}, text={self.text!r}, "
|
66
|
+
f"type={self.type!r}, status={self.status!r}, elements={self.elements!r})"
|
67
|
+
)
|
68
|
+
|
69
|
+
def __str__(self) -> str:
|
70
|
+
return f"Message {self.id} from {self.sender}: {self.text}"
|
71
|
+
|
72
|
+
|
73
|
+
class Dialog:
|
74
|
+
def __init__(
|
75
|
+
self,
|
76
|
+
cid: int | None,
|
77
|
+
owner: int,
|
78
|
+
has_bots: bool | None,
|
79
|
+
join_time: int,
|
80
|
+
created: int,
|
81
|
+
last_message: Message | None,
|
82
|
+
type: ChatType | str,
|
83
|
+
last_fire_delayed_error_time: int,
|
84
|
+
last_delayed_update_time: int,
|
85
|
+
prev_message_id: str | None,
|
86
|
+
options: dict[str, bool],
|
87
|
+
modified: int,
|
88
|
+
last_event_time: int,
|
89
|
+
id: int,
|
90
|
+
status: str,
|
91
|
+
participants: dict[str, int],
|
92
|
+
) -> None:
|
93
|
+
self.cid = cid
|
94
|
+
self.owner = owner
|
95
|
+
self.has_bots = has_bots
|
96
|
+
self.join_time = join_time
|
97
|
+
self.created = created
|
98
|
+
self.last_message = last_message
|
99
|
+
self.type = type
|
100
|
+
self.last_fire_delayed_error_time = last_fire_delayed_error_time
|
101
|
+
self.last_delayed_update_time = last_delayed_update_time
|
102
|
+
self.prev_message_id = prev_message_id
|
103
|
+
self.options = options
|
104
|
+
self.modified = modified
|
105
|
+
self.last_event_time = last_event_time
|
106
|
+
self.id = id
|
107
|
+
self.status = status
|
108
|
+
self.participants = participants
|
109
|
+
|
110
|
+
@classmethod
|
111
|
+
def from_dict(cls, data: dict[Any, Any]) -> "Dialog":
|
112
|
+
return cls(
|
113
|
+
cid=data.get("cid"),
|
114
|
+
owner=data["owner"],
|
115
|
+
has_bots=data.get("hasBots"),
|
116
|
+
join_time=data["joinTime"],
|
117
|
+
created=data["created"],
|
118
|
+
last_message=Message.from_dict(data["lastMessage"])
|
119
|
+
if data.get("lastMessage")
|
120
|
+
else None,
|
121
|
+
type=ChatType(data["type"]),
|
122
|
+
last_fire_delayed_error_time=data["lastFireDelayedErrorTime"],
|
123
|
+
last_delayed_update_time=data["lastDelayedUpdateTime"],
|
124
|
+
prev_message_id=data.get("prevMessageId"),
|
125
|
+
options=data.get("options", {}),
|
126
|
+
modified=data["modified"],
|
127
|
+
last_event_time=data["lastEventTime"],
|
128
|
+
id=data["id"],
|
129
|
+
status=data["status"],
|
130
|
+
participants=data["participants"],
|
131
|
+
)
|
132
|
+
|
133
|
+
def __repr__(self) -> str:
|
134
|
+
return f"Dialog(id={self.id!r}, owner={self.owner!r}, type={self.type!r}, last_message={self.last_message!r})"
|
135
|
+
|
136
|
+
def __str__(self) -> str:
|
137
|
+
return f"Dialog {self.id} ({self.type})"
|
138
|
+
|
139
|
+
|
140
|
+
class Chat:
|
141
|
+
def __init__(
|
142
|
+
self,
|
143
|
+
participants_count: int,
|
144
|
+
access: AccessType | str,
|
145
|
+
invited_by: int | None,
|
146
|
+
link: str | None,
|
147
|
+
chat_type: ChatType | str,
|
148
|
+
title: str | None,
|
149
|
+
last_fire_delayed_error_time: int,
|
150
|
+
last_delayed_update_time: int,
|
151
|
+
options: dict[str, bool],
|
152
|
+
base_raw_icon_url: str | None,
|
153
|
+
base_icon_url: str | None,
|
154
|
+
description: str | None,
|
155
|
+
modified: int,
|
156
|
+
id_: int,
|
157
|
+
admin_participants: dict[int, dict[Any, Any]],
|
158
|
+
participants: dict[int, int],
|
159
|
+
owner: int,
|
160
|
+
join_time: int,
|
161
|
+
created: int,
|
162
|
+
last_message: Message | None,
|
163
|
+
prev_message_id: str | None,
|
164
|
+
last_event_time: int,
|
165
|
+
messages_count: int,
|
166
|
+
admins: list[int],
|
167
|
+
restrictions: int | None,
|
168
|
+
status: str,
|
169
|
+
cid: int,
|
170
|
+
) -> None:
|
171
|
+
self.participants_count = participants_count
|
172
|
+
self.access = access
|
173
|
+
self.invited_by = invited_by
|
174
|
+
self.link = link
|
175
|
+
self.type = chat_type
|
176
|
+
self.title = title
|
177
|
+
self.last_fire_delayed_error_time = last_fire_delayed_error_time
|
178
|
+
self.last_delayed_update_time = last_delayed_update_time
|
179
|
+
self.options = options
|
180
|
+
self.base_raw_icon_url = base_raw_icon_url
|
181
|
+
self.base_icon_url = base_icon_url
|
182
|
+
self.description = description
|
183
|
+
self.modified = modified
|
184
|
+
self.id = id_
|
185
|
+
self.admin_participants = admin_participants
|
186
|
+
self.participants = participants
|
187
|
+
self.owner = owner
|
188
|
+
self.join_time = join_time
|
189
|
+
self.created = created
|
190
|
+
self.last_message = last_message
|
191
|
+
self.prev_message_id = prev_message_id
|
192
|
+
self.last_event_time = last_event_time
|
193
|
+
self.messages_count = messages_count
|
194
|
+
self.admins = admins
|
195
|
+
self.restrictions = restrictions
|
196
|
+
self.status = status
|
197
|
+
self.cid = cid
|
198
|
+
|
199
|
+
@classmethod
|
200
|
+
def from_dict(cls, data: dict[Any, Any]) -> "Chat":
|
201
|
+
raw_admins = data.get("adminParticipants", {}) or {}
|
202
|
+
admin_participants: dict[int, dict[Any, Any]] = {int(k): v for k, v in raw_admins.items()}
|
203
|
+
raw_participants = data.get("participants", {}) or {}
|
204
|
+
participants: dict[int, int] = {int(k): v for k, v in raw_participants.items()}
|
205
|
+
last_msg = Message.from_dict(data["lastMessage"]) if data.get("lastMessage") else None
|
206
|
+
return cls(
|
207
|
+
participants_count=data.get("participantsCount", 0),
|
208
|
+
access=AccessType(data.get("access", AccessType.PUBLIC.value)),
|
209
|
+
invited_by=data.get("invitedBy"),
|
210
|
+
link=data.get("link"),
|
211
|
+
base_raw_icon_url=data.get("baseRawIconUrl"),
|
212
|
+
base_icon_url=data.get("baseIconUrl"),
|
213
|
+
description=data.get("description"),
|
214
|
+
chat_type=ChatType(data.get("type", ChatType.CHAT.value)),
|
215
|
+
title=data.get("title"),
|
216
|
+
last_fire_delayed_error_time=data.get("lastFireDelayedErrorTime", 0),
|
217
|
+
last_delayed_update_time=data.get("lastDelayedUpdateTime", 0),
|
218
|
+
options=data.get("options", {}),
|
219
|
+
modified=data.get("modified", 0),
|
220
|
+
id_=data.get("id", 0),
|
221
|
+
admin_participants=admin_participants,
|
222
|
+
participants=participants,
|
223
|
+
owner=data.get("owner", 0),
|
224
|
+
join_time=data.get("joinTime", 0),
|
225
|
+
created=data.get("created", 0),
|
226
|
+
last_message=last_msg,
|
227
|
+
prev_message_id=data.get("prevMessageId"),
|
228
|
+
last_event_time=data.get("lastEventTime", 0),
|
229
|
+
messages_count=data.get("messagesCount", 0),
|
230
|
+
admins=data.get("admins", []),
|
231
|
+
restrictions=data.get("restrictions"),
|
232
|
+
status=data.get("status", ""),
|
233
|
+
cid=data.get("cid", 0),
|
234
|
+
)
|
235
|
+
|
236
|
+
def __repr__(self) -> str:
|
237
|
+
return f"Chat(id={self.id!r}, title={self.title!r}, type={self.type!r})"
|
238
|
+
|
239
|
+
def __str__(self) -> str:
|
240
|
+
return f"{self.title} ({self.type})"
|
241
|
+
|
242
|
+
|
243
|
+
class Channel(Chat):
|
244
|
+
def __repr__(self) -> str:
|
245
|
+
return f"Channel(id={self.id!r}, title={self.title!r})"
|
246
|
+
|
247
|
+
def __str__(self) -> str:
|
248
|
+
return f"Channel: {self.title}"
|
249
|
+
|
250
|
+
|
251
|
+
class Names:
|
252
|
+
def __init__(self, name: str, first_name: str, last_name: str | None, type: str) -> None:
|
253
|
+
self.name = name
|
254
|
+
self.first_name = first_name
|
255
|
+
self.last_name = last_name
|
256
|
+
self.type = type
|
257
|
+
|
258
|
+
@classmethod
|
259
|
+
def from_dict(cls, data: dict[str, Any]) -> "Names":
|
260
|
+
return cls(
|
261
|
+
name=data["name"],
|
262
|
+
first_name=data["firstName"],
|
263
|
+
last_name=data.get("lastName"),
|
264
|
+
type=data["type"],
|
265
|
+
)
|
266
|
+
|
267
|
+
def __repr__(self) -> str:
|
268
|
+
return f"Names(name={self.name!r}, first_name={self.first_name!r}, last_name={self.last_name!r}, type={self.type!r})"
|
269
|
+
|
270
|
+
def __str__(self) -> str:
|
271
|
+
return self.name
|
272
|
+
|
273
|
+
|
274
|
+
class User:
|
275
|
+
def __init__(
|
276
|
+
self,
|
277
|
+
account_status: int,
|
278
|
+
update_time: int,
|
279
|
+
id: int,
|
280
|
+
names: list[Names],
|
281
|
+
options: list[str] | None = None,
|
282
|
+
base_url: str | None = None,
|
283
|
+
base_raw_url: str | None = None,
|
284
|
+
photo_id: int | None = None,
|
285
|
+
description: str | None = None,
|
286
|
+
gender: int | None = None,
|
287
|
+
link: str | None = None,
|
288
|
+
web_app: str | None = None,
|
289
|
+
menu_button: dict[str, Any] | None = None,
|
290
|
+
) -> None:
|
291
|
+
self.account_status = account_status
|
292
|
+
self.update_time = update_time
|
293
|
+
self.id = id
|
294
|
+
self.names = names
|
295
|
+
self.options = options or []
|
296
|
+
self.base_url = base_url
|
297
|
+
self.base_raw_url = base_raw_url
|
298
|
+
self.photo_id = photo_id
|
299
|
+
self.description = description
|
300
|
+
self.gender = gender
|
301
|
+
self.link = link
|
302
|
+
self.web_app = web_app
|
303
|
+
self.menu_button = menu_button
|
304
|
+
|
305
|
+
@classmethod
|
306
|
+
def from_dict(cls, data: dict[str, Any]) -> "User":
|
307
|
+
return cls(
|
308
|
+
account_status=data["accountStatus"],
|
309
|
+
update_time=data["updateTime"],
|
310
|
+
id=data["id"],
|
311
|
+
names=[Names.from_dict(n) for n in data.get("names", [])],
|
312
|
+
options=data.get("options"),
|
313
|
+
base_url=data.get("baseUrl"),
|
314
|
+
base_raw_url=data.get("baseRawUrl"),
|
315
|
+
photo_id=data.get("photoId"),
|
316
|
+
description=data.get("description"),
|
317
|
+
gender=data.get("gender"),
|
318
|
+
link=data.get("link"),
|
319
|
+
web_app=data.get("webApp"),
|
320
|
+
menu_button=data.get("menuButton"),
|
321
|
+
)
|
322
|
+
|
323
|
+
def __repr__(self) -> str:
|
324
|
+
return f"User(id={self.id!r}, names={self.names!r}, status={self.account_status!r})"
|
325
|
+
|
326
|
+
def __str__(self) -> str:
|
327
|
+
return f"User {self.id}: {', '.join(str(n) for n in self.names)}"
|