maxapi-python 1.1.15__tar.gz → 1.1.17__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.
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/.gitignore +2 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/PKG-INFO +7 -3
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/README.md +6 -2
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/examples/example.py +24 -17
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/examples/telegram_bridge.py +13 -5
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/pyproject.toml +1 -1
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/__init__.py +55 -1
- maxapi_python-1.1.17/src/pymax/core.py +342 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/exceptions.py +51 -9
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/files.py +12 -4
- maxapi_python-1.1.17/src/pymax/formatter.py +30 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/interfaces.py +8 -3
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/mixins/auth.py +12 -7
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/mixins/channel.py +31 -13
- maxapi_python-1.1.17/src/pymax/mixins/group.py +260 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/mixins/handler.py +12 -8
- maxapi_python-1.1.17/src/pymax/mixins/message.py +672 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/mixins/self.py +7 -6
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/mixins/socket.py +59 -82
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/mixins/telemetry.py +7 -12
- maxapi_python-1.1.17/src/pymax/mixins/user.py +200 -0
- maxapi_python-1.1.17/src/pymax/mixins/utils.py +27 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/mixins/websocket.py +19 -14
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/payloads.py +7 -2
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/static/enum.py +18 -12
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/types.py +56 -22
- maxapi_python-1.1.15/src/pymax/core.py +0 -197
- maxapi_python-1.1.15/src/pymax/mixins/group.py +0 -310
- maxapi_python-1.1.15/src/pymax/mixins/message.py +0 -689
- maxapi_python-1.1.15/src/pymax/mixins/user.py +0 -155
- maxapi_python-1.1.15/src/pymax/utils.py +0 -46
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/.github/FUNDING.yml +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/.github/ISSUE_TEMPLATE/refactor.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/.github/pull_request_template.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/.github/workflows/publish.yml +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/.pre-commit-config.yaml +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/LICENSE +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/assets/icon.svg +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/assets/logo.svg +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/docs/api.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/docs/assets/icon.svg +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/docs/client.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/docs/examples.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/docs/index.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/docs/methods.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/docs/types.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/mkdocs.yml +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/ruff.toml +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/crud.py +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/filters.py +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/formatting.py +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/mixins/__init__.py +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/models.py +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/navigation.py +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.17}/src/pymax/static/constant.py +0 -0
|
@@ -103,6 +103,7 @@ Thumbs.db
|
|
|
103
103
|
session.db
|
|
104
104
|
*.bak
|
|
105
105
|
*.swp
|
|
106
|
+
*.bin
|
|
106
107
|
|
|
107
108
|
# Environment / secrets
|
|
108
109
|
.env
|
|
@@ -114,6 +115,7 @@ cache/
|
|
|
114
115
|
|
|
115
116
|
# Keep lockfiles and important configs tracked? If you want to track specific lockfiles,
|
|
116
117
|
# remove them from this .gitignore (for example: remove poetry.lock or uv.lock).
|
|
118
|
+
tests2/
|
|
117
119
|
tests/
|
|
118
120
|
|
|
119
121
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: maxapi-python
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.17
|
|
4
4
|
Summary: Python wrapper для API мессенджера Max
|
|
5
5
|
Project-URL: Homepage, https://github.com/ink-developer/PyMax
|
|
6
6
|
Project-URL: Repository, https://github.com/ink-developer/PyMax
|
|
@@ -155,10 +155,14 @@ if __name__ == "__main__":
|
|
|
155
155
|
|
|
156
156
|
[Telegram](https://t.me/pymax_news)
|
|
157
157
|
|
|
158
|
-
##
|
|
158
|
+
## Star History
|
|
159
|
+
|
|
160
|
+
[](https://www.star-history.com/#ink-developer/PyMax&type=date&legend=top-left)
|
|
159
161
|
|
|
160
|
-
|
|
162
|
+
## Авторы
|
|
161
163
|
- **[ink](https://github.com/ink-developer)** — Главный разработчик, исследование API и его документация
|
|
164
|
+
- **[noxzion](https://github.com/noxzion)** — Оригинальный автор проекта
|
|
165
|
+
|
|
162
166
|
|
|
163
167
|
## Контрибьюторы
|
|
164
168
|
|
|
@@ -133,10 +133,14 @@ if __name__ == "__main__":
|
|
|
133
133
|
|
|
134
134
|
[Telegram](https://t.me/pymax_news)
|
|
135
135
|
|
|
136
|
-
##
|
|
136
|
+
## Star History
|
|
137
|
+
|
|
138
|
+
[](https://www.star-history.com/#ink-developer/PyMax&type=date&legend=top-left)
|
|
137
139
|
|
|
138
|
-
|
|
140
|
+
## Авторы
|
|
139
141
|
- **[ink](https://github.com/ink-developer)** — Главный разработчик, исследование API и его документация
|
|
142
|
+
- **[noxzion](https://github.com/noxzion)** — Оригинальный автор проекта
|
|
143
|
+
|
|
140
144
|
|
|
141
145
|
## Контрибьюторы
|
|
142
146
|
|
|
@@ -30,23 +30,30 @@ async def handle_deleted_message(message: Message) -> None:
|
|
|
30
30
|
@client.on_start
|
|
31
31
|
async def handle_start() -> None:
|
|
32
32
|
print(f"Client started successfully at {datetime.datetime.now()}!")
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
33
|
+
print(client.me.id)
|
|
34
|
+
|
|
35
|
+
# await client.send_message(
|
|
36
|
+
# "Hello, this is a test message sent upon client start!",
|
|
37
|
+
# chat_id=23424,
|
|
38
|
+
# notify=True,
|
|
39
|
+
# )
|
|
40
|
+
# file_path = "ruff.toml"
|
|
41
|
+
# file = File(path=file_path)
|
|
42
|
+
# msg = await client.send_message(
|
|
43
|
+
# text="Here is the file you requested.",
|
|
44
|
+
# chat_id=0,
|
|
45
|
+
# attachment=file,
|
|
46
|
+
# notify=True,
|
|
47
|
+
# )
|
|
48
|
+
# if msg:
|
|
49
|
+
# print(f"File sent successfully in message ID: {msg.id}")
|
|
50
|
+
history = await client.fetch_history(chat_id=0)
|
|
51
|
+
if history:
|
|
52
|
+
for message in history:
|
|
53
|
+
if message.attaches:
|
|
54
|
+
for attach in message.attaches:
|
|
55
|
+
if attach.type == AttachType.AUDIO:
|
|
56
|
+
print(attach.url)
|
|
50
57
|
# chat = await client.rework_invite_link(chat_id=0)
|
|
51
58
|
# print(chat.link)
|
|
52
59
|
# text = """
|
|
@@ -75,14 +75,17 @@ async def handle_message(message: Message) -> None:
|
|
|
75
75
|
async with session.get(video.url) as response:
|
|
76
76
|
response.raise_for_status() # Проверка на ошибки HTTP
|
|
77
77
|
video_bytes = BytesIO(await response.read())
|
|
78
|
-
video_bytes.name = response.headers.get(
|
|
78
|
+
video_bytes.name = response.headers.get(
|
|
79
|
+
"X-File-Name"
|
|
80
|
+
)
|
|
79
81
|
|
|
80
82
|
# Отправляем видео через телеграм бота
|
|
81
83
|
await telegram_bot.send_video(
|
|
82
84
|
chat_id=tg_id,
|
|
83
85
|
caption=f"{sender.names[0].name}: {message.text}",
|
|
84
86
|
video=types.BufferedInputFile(
|
|
85
|
-
video_bytes.getvalue(),
|
|
87
|
+
video_bytes.getvalue(),
|
|
88
|
+
filename=video_bytes.name,
|
|
86
89
|
),
|
|
87
90
|
)
|
|
88
91
|
|
|
@@ -102,14 +105,17 @@ async def handle_message(message: Message) -> None:
|
|
|
102
105
|
async with session.get(attach.base_url) as response:
|
|
103
106
|
response.raise_for_status() # Проверка на ошибки HTTP
|
|
104
107
|
photo_bytes = BytesIO(await response.read())
|
|
105
|
-
photo_bytes.name = response.headers.get(
|
|
108
|
+
photo_bytes.name = response.headers.get(
|
|
109
|
+
"X-File-Name"
|
|
110
|
+
)
|
|
106
111
|
|
|
107
112
|
# Отправляем фото через телеграм бота
|
|
108
113
|
await telegram_bot.send_photo(
|
|
109
114
|
chat_id=tg_id,
|
|
110
115
|
caption=f"{sender.names[0].name}: {message.text}",
|
|
111
116
|
photo=types.BufferedInputFile(
|
|
112
|
-
photo_bytes.getvalue(),
|
|
117
|
+
photo_bytes.getvalue(),
|
|
118
|
+
filename=photo_bytes.name,
|
|
113
119
|
),
|
|
114
120
|
)
|
|
115
121
|
|
|
@@ -136,7 +142,9 @@ async def handle_message(message: Message) -> None:
|
|
|
136
142
|
async with session.get(file.url) as response:
|
|
137
143
|
response.raise_for_status() # Проверка на ошибки HTTP
|
|
138
144
|
file_bytes = BytesIO(await response.read())
|
|
139
|
-
file_bytes.name = response.headers.get(
|
|
145
|
+
file_bytes.name = response.headers.get(
|
|
146
|
+
"X-File-Name"
|
|
147
|
+
)
|
|
140
148
|
|
|
141
149
|
# Отправляем файл через телеграм бота
|
|
142
150
|
await telegram_bot.send_document(
|
|
@@ -9,14 +9,26 @@ from .core import (
|
|
|
9
9
|
from .exceptions import (
|
|
10
10
|
InvalidPhoneError,
|
|
11
11
|
LoginError,
|
|
12
|
+
ResponseError,
|
|
13
|
+
ResponseStructureError,
|
|
14
|
+
SocketNotConnectedError,
|
|
15
|
+
SocketSendError,
|
|
12
16
|
WebSocketNotConnectedError,
|
|
13
17
|
)
|
|
18
|
+
from .files import (
|
|
19
|
+
File,
|
|
20
|
+
Photo,
|
|
21
|
+
)
|
|
14
22
|
from .static.enum import (
|
|
15
23
|
AccessType,
|
|
24
|
+
AttachType,
|
|
16
25
|
AuthType,
|
|
17
26
|
ChatType,
|
|
27
|
+
ContactAction,
|
|
18
28
|
DeviceType,
|
|
19
29
|
ElementType,
|
|
30
|
+
FormattingType,
|
|
31
|
+
MarkupType,
|
|
20
32
|
MessageStatus,
|
|
21
33
|
MessageType,
|
|
22
34
|
Opcode,
|
|
@@ -24,10 +36,26 @@ from .static.enum import (
|
|
|
24
36
|
from .types import (
|
|
25
37
|
Channel,
|
|
26
38
|
Chat,
|
|
39
|
+
Contact,
|
|
40
|
+
ControlAttach,
|
|
27
41
|
Dialog,
|
|
28
42
|
Element,
|
|
43
|
+
FileAttach,
|
|
44
|
+
FileRequest,
|
|
45
|
+
Me,
|
|
46
|
+
Member,
|
|
29
47
|
Message,
|
|
48
|
+
MessageLink,
|
|
49
|
+
Name,
|
|
50
|
+
Names,
|
|
51
|
+
PhotoAttach,
|
|
52
|
+
Presence,
|
|
53
|
+
ReactionCounter,
|
|
54
|
+
ReactionInfo,
|
|
55
|
+
Session,
|
|
30
56
|
User,
|
|
57
|
+
VideoAttach,
|
|
58
|
+
VideoRequest,
|
|
31
59
|
)
|
|
32
60
|
|
|
33
61
|
__author__ = "ink-developer"
|
|
@@ -35,25 +63,51 @@ __author__ = "ink-developer"
|
|
|
35
63
|
__all__ = [
|
|
36
64
|
# Перечисления и константы
|
|
37
65
|
"AccessType",
|
|
66
|
+
"AttachType",
|
|
38
67
|
"AuthType",
|
|
39
68
|
# Типы данных
|
|
40
69
|
"Channel",
|
|
41
70
|
"Chat",
|
|
42
71
|
"ChatType",
|
|
72
|
+
"Contact",
|
|
73
|
+
"ContactAction",
|
|
74
|
+
"ControlAttach",
|
|
43
75
|
"DeviceType",
|
|
44
76
|
"Dialog",
|
|
45
77
|
"Element",
|
|
46
78
|
"ElementType",
|
|
79
|
+
"File",
|
|
80
|
+
"FileAttach",
|
|
81
|
+
"FileRequest",
|
|
82
|
+
"FormattingType",
|
|
47
83
|
# Исключения
|
|
48
84
|
"InvalidPhoneError",
|
|
49
85
|
"LoginError",
|
|
50
|
-
"
|
|
86
|
+
"MarkupType",
|
|
51
87
|
# Клиент
|
|
52
88
|
"MaxClient",
|
|
89
|
+
"Me",
|
|
90
|
+
"Member",
|
|
53
91
|
"Message",
|
|
92
|
+
"MessageLink",
|
|
54
93
|
"MessageStatus",
|
|
55
94
|
"MessageType",
|
|
95
|
+
"Name",
|
|
96
|
+
"Names",
|
|
56
97
|
"Opcode",
|
|
98
|
+
"Photo",
|
|
99
|
+
"PhotoAttach",
|
|
100
|
+
"Presence",
|
|
101
|
+
"ReactionCounter",
|
|
102
|
+
"ReactionInfo",
|
|
103
|
+
"ResponseError",
|
|
104
|
+
"ResponseStructureError",
|
|
105
|
+
"Session",
|
|
57
106
|
"SocketMaxClient",
|
|
107
|
+
"SocketNotConnectedError",
|
|
108
|
+
"SocketSendError",
|
|
58
109
|
"User",
|
|
110
|
+
"VideoAttach",
|
|
111
|
+
"VideoRequest",
|
|
112
|
+
"WebSocketNotConnectedError",
|
|
59
113
|
]
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import logging
|
|
4
|
+
import socket
|
|
5
|
+
import ssl
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
from collections.abc import Awaitable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
11
|
+
|
|
12
|
+
from typing_extensions import override
|
|
13
|
+
|
|
14
|
+
from .crud import Database
|
|
15
|
+
from .exceptions import InvalidPhoneError, WebSocketNotConnectedError
|
|
16
|
+
from .formatter import ColoredFormatter
|
|
17
|
+
from .mixins import ApiMixin, SocketMixin, WebSocketMixin
|
|
18
|
+
from .payloads import UserAgentPayload
|
|
19
|
+
from .static.constant import (
|
|
20
|
+
HOST,
|
|
21
|
+
PORT,
|
|
22
|
+
WEBSOCKET_URI,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Awaitable, Callable
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import websockets
|
|
30
|
+
|
|
31
|
+
from .filters import Filter
|
|
32
|
+
from .types import Channel, Chat, Dialog, Me, Message, User
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MaxClient(ApiMixin, WebSocketMixin):
|
|
39
|
+
"""
|
|
40
|
+
Основной клиент для работы с WebSocket API сервиса Max.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
phone (str): Номер телефона для авторизации.
|
|
45
|
+
uri (str, optional): URI WebSocket сервера. По умолчанию Constants.WEBSOCKET_URI.value.
|
|
46
|
+
work_dir (str, optional): Рабочая директория для хранения базы данных. По умолчанию ".".
|
|
47
|
+
logger (logging.Logger | None): Пользовательский логгер. Если не передан — используется
|
|
48
|
+
логгер модуля с именем f"{__name__}.MaxClient".
|
|
49
|
+
headers (UserAgentPayload): Заголовки для подключения к WebSocket.
|
|
50
|
+
token (str | None, optional): Токен авторизации. Если не передан, будет выполнен
|
|
51
|
+
процесс логина по номеру телефона.
|
|
52
|
+
host (str, optional): Хост API сервера. По умолчанию Constants.HOST.value.
|
|
53
|
+
port (int, optional): Порт API сервера. По умолчанию Constants.PORT.value.
|
|
54
|
+
registration (bool, optional): Флаг регистрации нового пользователя. По умолчанию False.
|
|
55
|
+
first_name (str, optional): Имя пользователя для регистрации. Требуется, если registration=True.
|
|
56
|
+
last_name (str | None, optional): Фамилия пользователя для регистрации.
|
|
57
|
+
send_fake_telemetry (bool, optional): Флаг отправки фейковой телеметрии. По умолчанию True.
|
|
58
|
+
proxy (str | Literal[True] | None, optional): Прокси для подключения к WebSocket.
|
|
59
|
+
(См. https://websockets.readthedocs.io/en/stable/topics/proxies.html).
|
|
60
|
+
reconnect (bool, optional): Флаг автоматического переподключения при потере соединения. По умолчанию True.
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
InvalidPhoneError: Если формат номера телефона неверный.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
phone: str,
|
|
70
|
+
uri: str = WEBSOCKET_URI,
|
|
71
|
+
headers: UserAgentPayload = UserAgentPayload(),
|
|
72
|
+
token: str | None = None,
|
|
73
|
+
send_fake_telemetry: bool = True,
|
|
74
|
+
host: str = HOST,
|
|
75
|
+
port: int = PORT,
|
|
76
|
+
proxy: str | Literal[True] | None = None,
|
|
77
|
+
work_dir: str = ".",
|
|
78
|
+
registration: bool = False,
|
|
79
|
+
first_name: str = "",
|
|
80
|
+
last_name: str | None = None,
|
|
81
|
+
logger: logging.Logger | None = None,
|
|
82
|
+
reconnect: bool = True,
|
|
83
|
+
reconnect_delay: float = 1.0,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.logger = logger or logging.getLogger(f"{__name__}")
|
|
86
|
+
self.uri: str = uri
|
|
87
|
+
self.phone: str = phone
|
|
88
|
+
if not self._check_phone():
|
|
89
|
+
raise InvalidPhoneError(self.phone)
|
|
90
|
+
self.host: str = host
|
|
91
|
+
self.port: int = port
|
|
92
|
+
self.registration: bool = registration
|
|
93
|
+
self.first_name: str = first_name
|
|
94
|
+
self.last_name: str | None = last_name
|
|
95
|
+
self.proxy: str | Literal[True] | None = proxy
|
|
96
|
+
self.reconnect: bool = reconnect
|
|
97
|
+
self.reconnect_delay: float = reconnect_delay
|
|
98
|
+
|
|
99
|
+
self.is_connected: bool = False
|
|
100
|
+
|
|
101
|
+
self.chats: list[Chat] = []
|
|
102
|
+
self.dialogs: list[Dialog] = []
|
|
103
|
+
self.channels: list[Channel] = []
|
|
104
|
+
self.me: Me | None = None
|
|
105
|
+
self._users: dict[int, User] = {}
|
|
106
|
+
|
|
107
|
+
self._work_dir: str = work_dir
|
|
108
|
+
self._database_path: Path = Path(work_dir) / "session.db"
|
|
109
|
+
self._database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
self._database_path.touch(exist_ok=True)
|
|
111
|
+
self._database = Database(self._work_dir)
|
|
112
|
+
|
|
113
|
+
self._incoming: asyncio.Queue[dict[str, Any]] | None = None
|
|
114
|
+
self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
|
|
115
|
+
self._recv_task: asyncio.Task[Any] | None = None
|
|
116
|
+
self._outgoing_task: asyncio.Task[Any] | None = None
|
|
117
|
+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
118
|
+
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
119
|
+
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
120
|
+
|
|
121
|
+
self._seq: int = 0
|
|
122
|
+
self._error_count: int = 0
|
|
123
|
+
self._circuit_breaker: bool = False
|
|
124
|
+
self._last_error_time: float = 0.0
|
|
125
|
+
|
|
126
|
+
self._device_id = self._database.get_device_id()
|
|
127
|
+
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
128
|
+
|
|
129
|
+
self._token = self._database.get_auth_token() or token
|
|
130
|
+
self.user_agent = headers
|
|
131
|
+
self._send_fake_telemetry: bool = send_fake_telemetry
|
|
132
|
+
self._session_id: int = int(time.time() * 1000)
|
|
133
|
+
self._action_id: int = 1
|
|
134
|
+
self._current_screen: str = "chats_list_tab"
|
|
135
|
+
|
|
136
|
+
self._on_message_handlers: list[
|
|
137
|
+
tuple[Callable[[Message], Any], Filter | None]
|
|
138
|
+
] = []
|
|
139
|
+
self._on_message_edit_handlers: list[
|
|
140
|
+
tuple[Callable[[Message], Any], Filter | None]
|
|
141
|
+
] = []
|
|
142
|
+
self._on_message_delete_handlers: list[
|
|
143
|
+
tuple[Callable[[Message], Any], Filter | None]
|
|
144
|
+
] = []
|
|
145
|
+
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
146
|
+
|
|
147
|
+
self._ssl_context = ssl.create_default_context()
|
|
148
|
+
self._ssl_context.set_ciphers("DEFAULT")
|
|
149
|
+
self._ssl_context.check_hostname = True
|
|
150
|
+
self._ssl_context.verify_mode = ssl.CERT_REQUIRED
|
|
151
|
+
self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
152
|
+
self._ssl_context.load_default_certs()
|
|
153
|
+
self._socket: socket.socket | None = None
|
|
154
|
+
self._ws: websockets.ClientConnection | None = None
|
|
155
|
+
|
|
156
|
+
self._setup_logger()
|
|
157
|
+
self.logger.debug(
|
|
158
|
+
"Initialized MaxClient uri=%s work_dir=%s",
|
|
159
|
+
self.uri,
|
|
160
|
+
self._work_dir,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def _setup_logger(self) -> None:
|
|
164
|
+
if not self.logger.handlers:
|
|
165
|
+
if not self.logger.level:
|
|
166
|
+
self.logger.setLevel(logging.INFO)
|
|
167
|
+
handler = logging.StreamHandler()
|
|
168
|
+
formatter = ColoredFormatter(
|
|
169
|
+
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
170
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
171
|
+
)
|
|
172
|
+
handler.setFormatter(formatter)
|
|
173
|
+
self.logger.addHandler(handler)
|
|
174
|
+
|
|
175
|
+
async def _wait_forever(self):
|
|
176
|
+
try:
|
|
177
|
+
await self.ws.wait_closed()
|
|
178
|
+
except asyncio.CancelledError:
|
|
179
|
+
self.logger.debug("wait_closed cancelled")
|
|
180
|
+
|
|
181
|
+
async def _safe_execute(self, coro, *, context: str = "unknown"):
|
|
182
|
+
"""
|
|
183
|
+
Безопасно выполняет пользовательскую корутину.
|
|
184
|
+
Логирует traceback, но не роняет event loop.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
return await coro
|
|
188
|
+
except Exception as e:
|
|
189
|
+
self.logger.error(
|
|
190
|
+
f"Unhandled exception in {context}: {e}\n{traceback.format_exc()}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
async def close(self) -> None:
|
|
194
|
+
try:
|
|
195
|
+
self.logger.info("Closing client")
|
|
196
|
+
if self._recv_task:
|
|
197
|
+
self._recv_task.cancel()
|
|
198
|
+
try:
|
|
199
|
+
await self._recv_task
|
|
200
|
+
except asyncio.CancelledError:
|
|
201
|
+
self.logger.debug("recv_task cancelled")
|
|
202
|
+
if self._outgoing_task:
|
|
203
|
+
self._outgoing_task.cancel()
|
|
204
|
+
try:
|
|
205
|
+
await self._outgoing_task
|
|
206
|
+
except asyncio.CancelledError:
|
|
207
|
+
self.logger.debug("outgoing_task cancelled")
|
|
208
|
+
if self._ws:
|
|
209
|
+
await self._ws.close()
|
|
210
|
+
self.is_connected = False
|
|
211
|
+
self.logger.info("Client closed")
|
|
212
|
+
except Exception:
|
|
213
|
+
self.logger.exception("Error closing client")
|
|
214
|
+
|
|
215
|
+
@override
|
|
216
|
+
def _create_safe_task(
|
|
217
|
+
self, coro: Awaitable[Any], *, name: str | None = None
|
|
218
|
+
) -> asyncio.Task[Any | None]:
|
|
219
|
+
async def runner():
|
|
220
|
+
try:
|
|
221
|
+
return await coro
|
|
222
|
+
except asyncio.CancelledError:
|
|
223
|
+
raise
|
|
224
|
+
except Exception as e:
|
|
225
|
+
self.logger.exception(
|
|
226
|
+
f"Unhandled exception in task {name or coro}: {e}",
|
|
227
|
+
exc_info=e,
|
|
228
|
+
)
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
task = asyncio.create_task(runner(), name=name)
|
|
232
|
+
self._background_tasks.add(task)
|
|
233
|
+
return task
|
|
234
|
+
|
|
235
|
+
async def start(self) -> None:
|
|
236
|
+
"""
|
|
237
|
+
Запускает клиент, подключается к WebSocket, авторизует
|
|
238
|
+
пользователя (если нужно) и запускает фоновый цикл.
|
|
239
|
+
Теперь включает безопасный reconnect-loop, если self.reconnect=True.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
while True:
|
|
243
|
+
try:
|
|
244
|
+
self.logger.info("Client starting")
|
|
245
|
+
await self._connect(self.user_agent)
|
|
246
|
+
|
|
247
|
+
if self.registration:
|
|
248
|
+
if not self.first_name:
|
|
249
|
+
raise ValueError("First name is required for registration")
|
|
250
|
+
await self._register(self.first_name, self.last_name)
|
|
251
|
+
|
|
252
|
+
if self._token and self._database.get_auth_token() is None:
|
|
253
|
+
self._database.update_auth_token(self._device_id, self._token)
|
|
254
|
+
|
|
255
|
+
if self._token is None:
|
|
256
|
+
await self._login()
|
|
257
|
+
else:
|
|
258
|
+
await self._sync()
|
|
259
|
+
|
|
260
|
+
if self._on_start_handler:
|
|
261
|
+
self.logger.debug("Calling on_start handler")
|
|
262
|
+
result = self._on_start_handler()
|
|
263
|
+
if asyncio.iscoroutine(result):
|
|
264
|
+
await self._safe_execute(result, context="on_start handler")
|
|
265
|
+
|
|
266
|
+
ping_task = asyncio.create_task(self._send_interactive_ping())
|
|
267
|
+
ping_task.add_done_callback(self._log_task_exception)
|
|
268
|
+
self._background_tasks.add(ping_task)
|
|
269
|
+
|
|
270
|
+
if self._send_fake_telemetry:
|
|
271
|
+
telemetry_task = asyncio.create_task(self._start())
|
|
272
|
+
telemetry_task.add_done_callback(self._log_task_exception)
|
|
273
|
+
self._background_tasks.add(telemetry_task)
|
|
274
|
+
|
|
275
|
+
await self._wait_forever()
|
|
276
|
+
self.logger.info("WebSocket closed (wait_forever exited)")
|
|
277
|
+
|
|
278
|
+
except Exception:
|
|
279
|
+
self.logger.exception("Client start iteration failed")
|
|
280
|
+
|
|
281
|
+
finally:
|
|
282
|
+
self.logger.debug("Cleaning up background tasks and pending futures")
|
|
283
|
+
|
|
284
|
+
for task in list(self._background_tasks):
|
|
285
|
+
task.cancel()
|
|
286
|
+
try:
|
|
287
|
+
await task
|
|
288
|
+
except asyncio.CancelledError:
|
|
289
|
+
pass
|
|
290
|
+
except Exception:
|
|
291
|
+
self.logger.debug(
|
|
292
|
+
"Background task raised during cancellation", exc_info=True
|
|
293
|
+
)
|
|
294
|
+
self._background_tasks.discard(task)
|
|
295
|
+
|
|
296
|
+
if self._recv_task:
|
|
297
|
+
self._recv_task.cancel()
|
|
298
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
299
|
+
await self._recv_task
|
|
300
|
+
self._recv_task = None
|
|
301
|
+
|
|
302
|
+
if self._outgoing_task:
|
|
303
|
+
self._outgoing_task.cancel()
|
|
304
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
305
|
+
await self._outgoing_task
|
|
306
|
+
self._outgoing_task = None
|
|
307
|
+
|
|
308
|
+
for fut in self._pending.values():
|
|
309
|
+
if not fut.done():
|
|
310
|
+
fut.set_exception(WebSocketNotConnectedError)
|
|
311
|
+
self._pending.clear()
|
|
312
|
+
|
|
313
|
+
if self._ws:
|
|
314
|
+
try:
|
|
315
|
+
await self._ws.close()
|
|
316
|
+
except Exception:
|
|
317
|
+
self.logger.debug(
|
|
318
|
+
"Error closing ws during cleanup", exc_info=True
|
|
319
|
+
)
|
|
320
|
+
self._ws = None
|
|
321
|
+
|
|
322
|
+
self.is_connected = False
|
|
323
|
+
self.logger.info("Client start() cleaned up")
|
|
324
|
+
|
|
325
|
+
if not self.reconnect:
|
|
326
|
+
self.logger.info("Reconnect disabled — exiting start()")
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
self.logger.info("Reconnect enabled — restarting client")
|
|
330
|
+
await asyncio.sleep(self.reconnect_delay)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class SocketMaxClient(SocketMixin, MaxClient):
|
|
334
|
+
@override
|
|
335
|
+
async def _wait_forever(self):
|
|
336
|
+
if self._recv_task:
|
|
337
|
+
try:
|
|
338
|
+
await self._recv_task
|
|
339
|
+
except asyncio.CancelledError:
|
|
340
|
+
self.logger.debug("Socket recv_task cancelled")
|
|
341
|
+
except Exception as e:
|
|
342
|
+
self.logger.exception("Socket recv_task failed: %s", e)
|
|
@@ -39,15 +39,6 @@ class SocketSendError(Exception):
|
|
|
39
39
|
super().__init__("Send and wait failed (socket)")
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
class LoginError(Exception):
|
|
43
|
-
"""
|
|
44
|
-
Исключение, вызываемое при ошибке авторизации.
|
|
45
|
-
"""
|
|
46
|
-
|
|
47
|
-
def __init__(self, message: str) -> None:
|
|
48
|
-
super().__init__(f"Login error: {message}")
|
|
49
|
-
|
|
50
|
-
|
|
51
42
|
class ResponseError(Exception):
|
|
52
43
|
"""
|
|
53
44
|
Исключение, вызываемое при ошибке в ответе от сервера.
|
|
@@ -64,3 +55,54 @@ class ResponseStructureError(Exception):
|
|
|
64
55
|
|
|
65
56
|
def __init__(self, message: str) -> None:
|
|
66
57
|
super().__init__(f"Response structure error: {message}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Error(Exception):
|
|
61
|
+
"""
|
|
62
|
+
Базовое исключение для ошибок PyMax.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
error: str,
|
|
68
|
+
message: str,
|
|
69
|
+
title: str,
|
|
70
|
+
localized_message: str | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
self.error = error
|
|
73
|
+
self.message = message
|
|
74
|
+
self.title = title
|
|
75
|
+
self.localized_message = localized_message
|
|
76
|
+
|
|
77
|
+
parts = []
|
|
78
|
+
if localized_message:
|
|
79
|
+
parts.append(localized_message)
|
|
80
|
+
if message:
|
|
81
|
+
parts.append(message)
|
|
82
|
+
if title:
|
|
83
|
+
parts.append(f"({title})")
|
|
84
|
+
parts.append(f"[{error}]")
|
|
85
|
+
|
|
86
|
+
super().__init__("PyMax Error: " + " ".join(parts))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RateLimitError(Error):
|
|
90
|
+
"""
|
|
91
|
+
Исключение, вызываемое при превышении лимита запросов.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self, error: str, message: str, title: str, localized_message: str | None = None
|
|
96
|
+
) -> None:
|
|
97
|
+
super().__init__(error, message, title, localized_message)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class LoginError(Error):
|
|
101
|
+
"""
|
|
102
|
+
Исключение, вызываемое при ошибке авторизации.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self, error: str, message: str, title: str, localized_message: str | None = None
|
|
107
|
+
) -> None:
|
|
108
|
+
super().__init__(error, message, title, localized_message)
|