maxapi-python 1.1.14__tar.gz → 1.1.16__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.14 → maxapi_python-1.1.16}/.gitignore +2 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/PKG-INFO +8 -4
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/README.md +7 -3
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/examples/example.py +27 -8
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/examples/telegram_bridge.py +13 -5
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/pyproject.toml +1 -1
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/__init__.py +48 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/core.py +117 -8
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/exceptions.py +51 -9
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/files.py +14 -0
- maxapi_python-1.1.16/src/pymax/formatter.py +30 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/interfaces.py +31 -1
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/mixins/auth.py +78 -10
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/mixins/channel.py +31 -13
- maxapi_python-1.1.16/src/pymax/mixins/group.py +260 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/mixins/handler.py +12 -8
- maxapi_python-1.1.16/src/pymax/mixins/message.py +672 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/mixins/self.py +7 -6
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/mixins/socket.py +134 -64
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/mixins/telemetry.py +7 -12
- maxapi_python-1.1.16/src/pymax/mixins/user.py +200 -0
- maxapi_python-1.1.16/src/pymax/mixins/utils.py +27 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/mixins/websocket.py +134 -14
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/payloads.py +28 -11
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/static/enum.py +18 -12
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/types.py +42 -35
- maxapi_python-1.1.14/src/pymax/mixins/group.py +0 -310
- maxapi_python-1.1.14/src/pymax/mixins/message.py +0 -628
- maxapi_python-1.1.14/src/pymax/mixins/user.py +0 -155
- maxapi_python-1.1.14/src/pymax/utils.py +0 -46
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/.github/FUNDING.yml +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/.github/ISSUE_TEMPLATE/refactor.md +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/.github/pull_request_template.md +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/.github/workflows/publish.yml +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/.pre-commit-config.yaml +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/LICENSE +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/assets/icon.svg +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/assets/logo.svg +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/docs/api.md +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/docs/assets/icon.svg +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/docs/client.md +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/docs/examples.md +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/docs/index.md +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/docs/methods.md +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/docs/types.md +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/mkdocs.yml +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/ruff.toml +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/crud.py +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/filters.py +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/formatting.py +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/mixins/__init__.py +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/models.py +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/src/pymax/navigation.py +0 -0
- {maxapi_python-1.1.14 → maxapi_python-1.1.16}/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.16
|
|
4
4
|
Summary: Python wrapper для API мессенджера Max
|
|
5
5
|
Project-URL: Homepage, https://github.com/ink-developer/PyMax
|
|
6
6
|
Project-URL: Repository, https://github.com/ink-developer/PyMax
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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,31 @@ 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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
print(f"Client started successfully at {datetime.datetime.now()}!")
|
|
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.STICKER:
|
|
56
|
+
# print(attach.lottie_url)
|
|
38
57
|
# chat = await client.rework_invite_link(chat_id=0)
|
|
39
58
|
# print(chat.link)
|
|
40
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,22 @@ from .core import (
|
|
|
9
9
|
from .exceptions import (
|
|
10
10
|
InvalidPhoneError,
|
|
11
11
|
LoginError,
|
|
12
|
+
ResponseError,
|
|
13
|
+
ResponseStructureError,
|
|
14
|
+
SocketNotConnectedError,
|
|
15
|
+
SocketSendError,
|
|
12
16
|
WebSocketNotConnectedError,
|
|
13
17
|
)
|
|
14
18
|
from .static.enum import (
|
|
15
19
|
AccessType,
|
|
20
|
+
AttachType,
|
|
16
21
|
AuthType,
|
|
17
22
|
ChatType,
|
|
23
|
+
ContactAction,
|
|
18
24
|
DeviceType,
|
|
19
25
|
ElementType,
|
|
26
|
+
FormattingType,
|
|
27
|
+
MarkupType,
|
|
20
28
|
MessageStatus,
|
|
21
29
|
MessageType,
|
|
22
30
|
Opcode,
|
|
@@ -24,10 +32,26 @@ from .static.enum import (
|
|
|
24
32
|
from .types import (
|
|
25
33
|
Channel,
|
|
26
34
|
Chat,
|
|
35
|
+
Contact,
|
|
36
|
+
ControlAttach,
|
|
27
37
|
Dialog,
|
|
28
38
|
Element,
|
|
39
|
+
FileAttach,
|
|
40
|
+
FileRequest,
|
|
41
|
+
Me,
|
|
42
|
+
Member,
|
|
29
43
|
Message,
|
|
44
|
+
MessageLink,
|
|
45
|
+
Name,
|
|
46
|
+
Names,
|
|
47
|
+
PhotoAttach,
|
|
48
|
+
Presence,
|
|
49
|
+
ReactionCounter,
|
|
50
|
+
ReactionInfo,
|
|
51
|
+
Session,
|
|
30
52
|
User,
|
|
53
|
+
VideoAttach,
|
|
54
|
+
VideoRequest,
|
|
31
55
|
)
|
|
32
56
|
|
|
33
57
|
__author__ = "ink-developer"
|
|
@@ -35,19 +59,43 @@ __author__ = "ink-developer"
|
|
|
35
59
|
__all__ = [
|
|
36
60
|
# Перечисления и константы
|
|
37
61
|
"AccessType",
|
|
62
|
+
"AttachType",
|
|
38
63
|
"AuthType",
|
|
64
|
+
"ContactAction",
|
|
65
|
+
"FormattingType",
|
|
66
|
+
"MarkupType",
|
|
39
67
|
# Типы данных
|
|
40
68
|
"Channel",
|
|
41
69
|
"Chat",
|
|
42
70
|
"ChatType",
|
|
71
|
+
"Contact",
|
|
72
|
+
"ControlAttach",
|
|
43
73
|
"DeviceType",
|
|
44
74
|
"Dialog",
|
|
45
75
|
"Element",
|
|
46
76
|
"ElementType",
|
|
77
|
+
"FileAttach",
|
|
78
|
+
"FileRequest",
|
|
79
|
+
"Me",
|
|
80
|
+
"Member",
|
|
81
|
+
"MessageLink",
|
|
82
|
+
"Name",
|
|
83
|
+
"Names",
|
|
84
|
+
"PhotoAttach",
|
|
85
|
+
"Presence",
|
|
86
|
+
"ReactionCounter",
|
|
87
|
+
"ReactionInfo",
|
|
88
|
+
"Session",
|
|
89
|
+
"VideoAttach",
|
|
90
|
+
"VideoRequest",
|
|
47
91
|
# Исключения
|
|
48
92
|
"InvalidPhoneError",
|
|
49
93
|
"LoginError",
|
|
50
94
|
"WebSocketNotConnectedError",
|
|
95
|
+
"ResponseError",
|
|
96
|
+
"ResponseStructureError",
|
|
97
|
+
"SocketNotConnectedError",
|
|
98
|
+
"SocketSendError",
|
|
51
99
|
# Клиент
|
|
52
100
|
"MaxClient",
|
|
53
101
|
"Message",
|
|
@@ -3,12 +3,16 @@ import logging
|
|
|
3
3
|
import socket
|
|
4
4
|
import ssl
|
|
5
5
|
import time
|
|
6
|
+
import traceback
|
|
7
|
+
from collections.abc import Awaitable
|
|
6
8
|
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
7
10
|
|
|
8
11
|
from typing_extensions import override
|
|
9
12
|
|
|
10
13
|
from .crud import Database
|
|
11
14
|
from .exceptions import InvalidPhoneError
|
|
15
|
+
from .formatter import ColoredFormatter
|
|
12
16
|
from .mixins import ApiMixin, SocketMixin, WebSocketMixin
|
|
13
17
|
from .payloads import UserAgentPayload
|
|
14
18
|
from .static.constant import (
|
|
@@ -17,6 +21,16 @@ from .static.constant import (
|
|
|
17
21
|
WEBSOCKET_URI,
|
|
18
22
|
)
|
|
19
23
|
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Awaitable, Callable
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import websockets
|
|
29
|
+
|
|
30
|
+
from .filters import Filter
|
|
31
|
+
from .types import Channel, Chat, Dialog, Me, Message, User
|
|
32
|
+
|
|
33
|
+
|
|
20
34
|
logger = logging.getLogger(__name__)
|
|
21
35
|
|
|
22
36
|
|
|
@@ -36,6 +50,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
36
50
|
процесс логина по номеру телефона.
|
|
37
51
|
host (str, optional): Хост API сервера. По умолчанию Constants.HOST.value.
|
|
38
52
|
port (int, optional): Порт API сервера. По умолчанию Constants.PORT.value.
|
|
53
|
+
registration (bool, optional): Флаг регистрации нового пользователя. По умолчанию False.
|
|
54
|
+
first_name (str, optional): Имя пользователя для регистрации. Требуется, если registration=True.
|
|
55
|
+
last_name (str | None, optional): Фамилия пользователя для регистрации.
|
|
56
|
+
send_fake_telemetry (bool, optional): Флаг отправки фейковой телеметрии. По умолчанию True.
|
|
57
|
+
proxy (str | Literal[True] | None, optional): Прокси для подключения к WebSocket.
|
|
58
|
+
(См. https://websockets.readthedocs.io/en/stable/topics/proxies.html).
|
|
39
59
|
|
|
40
60
|
Raises:
|
|
41
61
|
InvalidPhoneError: Если формат номера телефона неверный.
|
|
@@ -50,29 +70,73 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
50
70
|
send_fake_telemetry: bool = True,
|
|
51
71
|
host: str = HOST,
|
|
52
72
|
port: int = PORT,
|
|
73
|
+
proxy: str | Literal[True] | None = None,
|
|
53
74
|
work_dir: str = ".",
|
|
75
|
+
registration: bool = False,
|
|
76
|
+
first_name: str = "",
|
|
77
|
+
last_name: str | None = None,
|
|
54
78
|
logger: logging.Logger | None = None,
|
|
55
79
|
) -> None:
|
|
56
|
-
logger = logger or logging.getLogger(f"{__name__}
|
|
57
|
-
ApiMixin.__init__(self, token=token, logger=logger)
|
|
58
|
-
WebSocketMixin.__init__(self, token=token, logger=logger)
|
|
80
|
+
self.logger = logger or logging.getLogger(f"{__name__}")
|
|
59
81
|
self.uri: str = uri
|
|
60
82
|
self.phone: str = phone
|
|
61
83
|
if not self._check_phone():
|
|
62
84
|
raise InvalidPhoneError(self.phone)
|
|
63
85
|
self.host: str = host
|
|
64
86
|
self.port: int = port
|
|
87
|
+
self.registration: bool = registration
|
|
88
|
+
self.first_name: str = first_name
|
|
89
|
+
self.last_name: str | None = last_name
|
|
90
|
+
self.proxy: str | Literal[True] | None = proxy
|
|
91
|
+
|
|
92
|
+
self.is_connected: bool = False
|
|
93
|
+
|
|
94
|
+
self.chats: list[Chat] = []
|
|
95
|
+
self.dialogs: list[Dialog] = []
|
|
96
|
+
self.channels: list[Channel] = []
|
|
97
|
+
self.me: Me | None = None
|
|
98
|
+
self._users: dict[int, User] = {}
|
|
99
|
+
|
|
65
100
|
self._work_dir: str = work_dir
|
|
66
101
|
self._database_path: Path = Path(work_dir) / "session.db"
|
|
67
102
|
self._database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
103
|
self._database_path.touch(exist_ok=True)
|
|
69
104
|
self._database = Database(self._work_dir)
|
|
105
|
+
|
|
106
|
+
self._incoming: asyncio.Queue[dict[str, Any]] | None = None
|
|
107
|
+
self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
|
|
108
|
+
self._recv_task: asyncio.Task[Any] | None = None
|
|
109
|
+
self._outgoing_task: asyncio.Task[Any] | None = None
|
|
110
|
+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
111
|
+
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
112
|
+
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
113
|
+
|
|
114
|
+
self._seq: int = 0
|
|
115
|
+
self._error_count: int = 0
|
|
116
|
+
self._circuit_breaker: bool = False
|
|
117
|
+
self._last_error_time: float = 0.0
|
|
118
|
+
|
|
70
119
|
self._device_id = self._database.get_device_id()
|
|
120
|
+
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
121
|
+
|
|
71
122
|
self._token = self._database.get_auth_token() or token
|
|
72
123
|
self.user_agent = headers
|
|
73
124
|
self._send_fake_telemetry: bool = send_fake_telemetry
|
|
74
125
|
self._session_id: int = int(time.time() * 1000)
|
|
75
126
|
self._action_id: int = 1
|
|
127
|
+
self._current_screen: str = "chats_list_tab"
|
|
128
|
+
|
|
129
|
+
self._on_message_handlers: list[
|
|
130
|
+
tuple[Callable[[Message], Any], Filter | None]
|
|
131
|
+
] = []
|
|
132
|
+
self._on_message_edit_handlers: list[
|
|
133
|
+
tuple[Callable[[Message], Any], Filter | None]
|
|
134
|
+
] = []
|
|
135
|
+
self._on_message_delete_handlers: list[
|
|
136
|
+
tuple[Callable[[Message], Any], Filter | None]
|
|
137
|
+
] = []
|
|
138
|
+
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
139
|
+
|
|
76
140
|
self._ssl_context = ssl.create_default_context()
|
|
77
141
|
self._ssl_context.set_ciphers("DEFAULT")
|
|
78
142
|
self._ssl_context.check_hostname = True
|
|
@@ -80,6 +144,8 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
80
144
|
self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
81
145
|
self._ssl_context.load_default_certs()
|
|
82
146
|
self._socket: socket.socket | None = None
|
|
147
|
+
self._ws: websockets.ClientConnection | None = None
|
|
148
|
+
|
|
83
149
|
self._setup_logger()
|
|
84
150
|
self.logger.debug(
|
|
85
151
|
"Initialized MaxClient uri=%s work_dir=%s",
|
|
@@ -88,13 +154,16 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
88
154
|
)
|
|
89
155
|
|
|
90
156
|
def _setup_logger(self) -> None:
|
|
91
|
-
if not logger.handlers:
|
|
157
|
+
if not self.logger.handlers:
|
|
158
|
+
if not self.logger.level:
|
|
159
|
+
self.logger.setLevel(logging.INFO)
|
|
92
160
|
handler = logging.StreamHandler()
|
|
93
|
-
formatter =
|
|
94
|
-
"%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
161
|
+
formatter = ColoredFormatter(
|
|
162
|
+
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
163
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
95
164
|
)
|
|
96
165
|
handler.setFormatter(formatter)
|
|
97
|
-
logger.addHandler(handler)
|
|
166
|
+
self.logger.addHandler(handler)
|
|
98
167
|
|
|
99
168
|
async def _wait_forever(self):
|
|
100
169
|
try:
|
|
@@ -102,6 +171,18 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
102
171
|
except asyncio.CancelledError:
|
|
103
172
|
self.logger.debug("wait_closed cancelled")
|
|
104
173
|
|
|
174
|
+
async def _safe_execute(self, coro, *, context: str = "unknown"):
|
|
175
|
+
"""
|
|
176
|
+
Безопасно выполняет пользовательскую корутину.
|
|
177
|
+
Логирует traceback, но не роняет event loop.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
return await coro
|
|
181
|
+
except Exception as e:
|
|
182
|
+
self.logger.error(
|
|
183
|
+
f"Unhandled exception in {context}: {e}\n{traceback.format_exc()}"
|
|
184
|
+
)
|
|
185
|
+
|
|
105
186
|
async def close(self) -> None:
|
|
106
187
|
try:
|
|
107
188
|
self.logger.info("Closing client")
|
|
@@ -111,6 +192,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
111
192
|
await self._recv_task
|
|
112
193
|
except asyncio.CancelledError:
|
|
113
194
|
self.logger.debug("recv_task cancelled")
|
|
195
|
+
if self._outgoing_task:
|
|
196
|
+
self._outgoing_task.cancel()
|
|
197
|
+
try:
|
|
198
|
+
await self._outgoing_task
|
|
199
|
+
except asyncio.CancelledError:
|
|
200
|
+
self.logger.debug("outgoing_task cancelled")
|
|
114
201
|
if self._ws:
|
|
115
202
|
await self._ws.close()
|
|
116
203
|
self.is_connected = False
|
|
@@ -118,6 +205,23 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
118
205
|
except Exception:
|
|
119
206
|
self.logger.exception("Error closing client")
|
|
120
207
|
|
|
208
|
+
def _create_safe_task(self, coro: Awaitable[Any], *, name: str | None = None):
|
|
209
|
+
async def runner():
|
|
210
|
+
try:
|
|
211
|
+
return await coro
|
|
212
|
+
except asyncio.CancelledError:
|
|
213
|
+
raise
|
|
214
|
+
except Exception as e:
|
|
215
|
+
self.logger.error(
|
|
216
|
+
f"Unhandled exception in task {name or coro}: {e}",
|
|
217
|
+
exc_info=e,
|
|
218
|
+
)
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
task = asyncio.create_task(runner(), name=name)
|
|
222
|
+
self._background_tasks.add(task)
|
|
223
|
+
return task
|
|
224
|
+
|
|
121
225
|
async def start(self) -> None:
|
|
122
226
|
"""
|
|
123
227
|
Запускает клиент, подключается к WebSocket, авторизует
|
|
@@ -127,6 +231,11 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
127
231
|
self.logger.info("Client starting")
|
|
128
232
|
await self._connect(self.user_agent)
|
|
129
233
|
|
|
234
|
+
if self.registration:
|
|
235
|
+
if not self.first_name:
|
|
236
|
+
raise ValueError("First name is required for registration")
|
|
237
|
+
await self._register(self.first_name, self.last_name)
|
|
238
|
+
|
|
130
239
|
if self._token and self._database.get_auth_token() is None:
|
|
131
240
|
self._database.update_auth_token(self._device_id, self._token)
|
|
132
241
|
|
|
@@ -139,7 +248,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
139
248
|
self.logger.debug("Calling on_start handler")
|
|
140
249
|
result = self._on_start_handler()
|
|
141
250
|
if asyncio.iscoroutine(result):
|
|
142
|
-
await result
|
|
251
|
+
await self._safe_execute(result, context="on_start handler")
|
|
143
252
|
|
|
144
253
|
ping_task = asyncio.create_task(self._send_interactive_ping())
|
|
145
254
|
ping_task.add_done_callback(self._log_task_exception)
|
|
@@ -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)
|
|
@@ -90,6 +90,20 @@ class Video(BaseFile):
|
|
|
90
90
|
|
|
91
91
|
|
|
92
92
|
class File(BaseFile):
|
|
93
|
+
def __init__(
|
|
94
|
+
self, url: str | None = None, path: str | None = None
|
|
95
|
+
) -> None:
|
|
96
|
+
self.file_name: str = ""
|
|
97
|
+
if path:
|
|
98
|
+
self.file_name = Path(path).name
|
|
99
|
+
elif url:
|
|
100
|
+
self.file_name = Path(url).name
|
|
101
|
+
|
|
102
|
+
if not self.file_name:
|
|
103
|
+
raise ValueError("Either url or path must be provided.")
|
|
104
|
+
|
|
105
|
+
super().__init__(url, path)
|
|
106
|
+
|
|
93
107
|
@override
|
|
94
108
|
async def read(self) -> bytes:
|
|
95
109
|
return await super().read()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ColoredFormatter(logging.Formatter):
|
|
6
|
+
COLORS: ClassVar = {
|
|
7
|
+
"DEBUG": "\033[37m",
|
|
8
|
+
"INFO": "\033[36m",
|
|
9
|
+
"WARNING": "\033[33m",
|
|
10
|
+
"ERROR": "\033[31m",
|
|
11
|
+
"CRITICAL": "\033[41m",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
RESET = "\033[0m"
|
|
15
|
+
DIM = "\033[2m"
|
|
16
|
+
BOLD = "\033[1m"
|
|
17
|
+
|
|
18
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
19
|
+
level_color = self.COLORS.get(record.levelname, self.RESET)
|
|
20
|
+
time_color = self.DIM
|
|
21
|
+
name_color = "\033[35m"
|
|
22
|
+
message_color = self.RESET
|
|
23
|
+
|
|
24
|
+
log = (
|
|
25
|
+
f"{time_color}{self.formatTime(record, '%H:%M:%S')}{self.RESET} "
|
|
26
|
+
f"[{level_color}{record.levelname}{self.RESET}] "
|
|
27
|
+
f"{name_color}{record.name}{self.RESET}: "
|
|
28
|
+
f"{message_color}{record.getMessage()}{self.RESET}"
|
|
29
|
+
)
|
|
30
|
+
return log
|
|
@@ -5,7 +5,7 @@ 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
|
|
|
@@ -37,6 +37,11 @@ class ClientProtocol(ABC):
|
|
|
37
37
|
self.me: Me | None = None
|
|
38
38
|
self.host: str
|
|
39
39
|
self.port: int
|
|
40
|
+
self.proxy: str | Literal[True] | None
|
|
41
|
+
self.registration: bool
|
|
42
|
+
self.first_name: str
|
|
43
|
+
self.last_name: str | None
|
|
44
|
+
self._token: str | None
|
|
40
45
|
self._work_dir: str
|
|
41
46
|
self._database_path: Path
|
|
42
47
|
self._ws: websockets.ClientConnection | None = None
|
|
@@ -44,7 +49,15 @@ class ClientProtocol(ABC):
|
|
|
44
49
|
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
45
50
|
self._recv_task: asyncio.Task[Any] | None = None
|
|
46
51
|
self._incoming: asyncio.Queue[dict[str, Any]] | None = None
|
|
52
|
+
self._file_upload_waiters: dict[
|
|
53
|
+
int, asyncio.Future[dict[str, Any]]
|
|
54
|
+
] = {}
|
|
47
55
|
self.user_agent = UserAgentPayload()
|
|
56
|
+
self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
|
|
57
|
+
self._outgoing_task: asyncio.Task[Any] | None = None
|
|
58
|
+
self._error_count: int = 0
|
|
59
|
+
self._circuit_breaker: bool = False
|
|
60
|
+
self._last_error_time: float = 0.0
|
|
48
61
|
self._session_id: int
|
|
49
62
|
self._action_id: int = 0
|
|
50
63
|
self._current_screen: str = "chats_list_tab"
|
|
@@ -77,3 +90,20 @@ class ClientProtocol(ABC):
|
|
|
77
90
|
@abstractmethod
|
|
78
91
|
async def _get_chat(self, chat_id: int) -> Chat | None:
|
|
79
92
|
pass
|
|
93
|
+
|
|
94
|
+
@abstractmethod
|
|
95
|
+
async def _queue_message(
|
|
96
|
+
self,
|
|
97
|
+
opcode: int,
|
|
98
|
+
payload: dict[str, Any],
|
|
99
|
+
cmd: int = 0,
|
|
100
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
101
|
+
max_retries: int = 3,
|
|
102
|
+
) -> Message | None:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
@abstractmethod
|
|
106
|
+
def _create_safe_task(
|
|
107
|
+
self, coro: Awaitable[Any], name: str | None = None
|
|
108
|
+
) -> asyncio.Task[Any]:
|
|
109
|
+
pass
|