maxapi-python 1.1.15__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.15 → maxapi_python-1.1.16}/.gitignore +2 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/PKG-INFO +7 -3
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/README.md +6 -2
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/examples/example.py +17 -10
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/examples/telegram_bridge.py +13 -5
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/pyproject.toml +1 -1
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/src/pymax/__init__.py +48 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/src/pymax/core.py +86 -9
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/src/pymax/exceptions.py +51 -9
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/src/pymax/files.py +12 -4
- maxapi_python-1.1.16/src/pymax/formatter.py +30 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/src/pymax/interfaces.py +13 -5
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/src/pymax/mixins/auth.py +12 -7
- {maxapi_python-1.1.15 → 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.15 → 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.15 → maxapi_python-1.1.16}/src/pymax/mixins/self.py +7 -6
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/src/pymax/mixins/socket.py +59 -82
- {maxapi_python-1.1.15 → 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.15 → maxapi_python-1.1.16}/src/pymax/mixins/websocket.py +49 -27
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/src/pymax/payloads.py +7 -2
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/src/pymax/static/enum.py +17 -12
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/src/pymax/types.py +42 -35
- 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.16}/.github/FUNDING.yml +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/.github/ISSUE_TEMPLATE/refactor.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/.github/pull_request_template.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/.github/workflows/publish.yml +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/.pre-commit-config.yaml +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/LICENSE +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/assets/icon.svg +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/assets/logo.svg +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/docs/api.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/docs/assets/icon.svg +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/docs/client.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/docs/examples.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/docs/index.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/docs/methods.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/docs/types.md +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/mkdocs.yml +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/ruff.toml +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/src/pymax/crud.py +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/src/pymax/filters.py +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/src/pymax/formatting.py +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/src/pymax/mixins/__init__.py +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/src/pymax/models.py +0 -0
- {maxapi_python-1.1.15 → maxapi_python-1.1.16}/src/pymax/navigation.py +0 -0
- {maxapi_python-1.1.15 → 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
|
|
@@ -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,16 +30,23 @@ 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
|
-
|
|
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}")
|
|
43
50
|
# history = await client.fetch_history(chat_id=0)
|
|
44
51
|
# if history:
|
|
45
52
|
# for message in history:
|
|
@@ -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,13 +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
|
|
7
|
-
from typing import Literal
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
8
10
|
|
|
9
11
|
from typing_extensions import override
|
|
10
12
|
|
|
11
13
|
from .crud import Database
|
|
12
14
|
from .exceptions import InvalidPhoneError
|
|
15
|
+
from .formatter import ColoredFormatter
|
|
13
16
|
from .mixins import ApiMixin, SocketMixin, WebSocketMixin
|
|
14
17
|
from .payloads import UserAgentPayload
|
|
15
18
|
from .static.constant import (
|
|
@@ -18,6 +21,16 @@ from .static.constant import (
|
|
|
18
21
|
WEBSOCKET_URI,
|
|
19
22
|
)
|
|
20
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
|
+
|
|
21
34
|
logger = logging.getLogger(__name__)
|
|
22
35
|
|
|
23
36
|
|
|
@@ -64,9 +77,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
64
77
|
last_name: str | None = None,
|
|
65
78
|
logger: logging.Logger | None = None,
|
|
66
79
|
) -> None:
|
|
67
|
-
logger = logger or logging.getLogger(f"{__name__}
|
|
68
|
-
ApiMixin.__init__(self, token=token, logger=logger)
|
|
69
|
-
WebSocketMixin.__init__(self, token=token, logger=logger)
|
|
80
|
+
self.logger = logger or logging.getLogger(f"{__name__}")
|
|
70
81
|
self.uri: str = uri
|
|
71
82
|
self.phone: str = phone
|
|
72
83
|
if not self._check_phone():
|
|
@@ -77,23 +88,55 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
77
88
|
self.first_name: str = first_name
|
|
78
89
|
self.last_name: str | None = last_name
|
|
79
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
|
+
|
|
80
100
|
self._work_dir: str = work_dir
|
|
81
101
|
self._database_path: Path = Path(work_dir) / "session.db"
|
|
82
102
|
self._database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
103
|
self._database_path.touch(exist_ok=True)
|
|
84
104
|
self._database = Database(self._work_dir)
|
|
105
|
+
|
|
106
|
+
self._incoming: asyncio.Queue[dict[str, Any]] | None = None
|
|
85
107
|
self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
|
|
108
|
+
self._recv_task: asyncio.Task[Any] | None = None
|
|
86
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
|
|
87
115
|
self._error_count: int = 0
|
|
88
116
|
self._circuit_breaker: bool = False
|
|
89
117
|
self._last_error_time: float = 0.0
|
|
118
|
+
|
|
90
119
|
self._device_id = self._database.get_device_id()
|
|
91
120
|
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
121
|
+
|
|
92
122
|
self._token = self._database.get_auth_token() or token
|
|
93
123
|
self.user_agent = headers
|
|
94
124
|
self._send_fake_telemetry: bool = send_fake_telemetry
|
|
95
125
|
self._session_id: int = int(time.time() * 1000)
|
|
96
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
|
+
|
|
97
140
|
self._ssl_context = ssl.create_default_context()
|
|
98
141
|
self._ssl_context.set_ciphers("DEFAULT")
|
|
99
142
|
self._ssl_context.check_hostname = True
|
|
@@ -101,6 +144,8 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
101
144
|
self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
102
145
|
self._ssl_context.load_default_certs()
|
|
103
146
|
self._socket: socket.socket | None = None
|
|
147
|
+
self._ws: websockets.ClientConnection | None = None
|
|
148
|
+
|
|
104
149
|
self._setup_logger()
|
|
105
150
|
self.logger.debug(
|
|
106
151
|
"Initialized MaxClient uri=%s work_dir=%s",
|
|
@@ -109,13 +154,16 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
109
154
|
)
|
|
110
155
|
|
|
111
156
|
def _setup_logger(self) -> None:
|
|
112
|
-
if not logger.handlers:
|
|
157
|
+
if not self.logger.handlers:
|
|
158
|
+
if not self.logger.level:
|
|
159
|
+
self.logger.setLevel(logging.INFO)
|
|
113
160
|
handler = logging.StreamHandler()
|
|
114
|
-
formatter =
|
|
115
|
-
"%(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",
|
|
116
164
|
)
|
|
117
165
|
handler.setFormatter(formatter)
|
|
118
|
-
logger.addHandler(handler)
|
|
166
|
+
self.logger.addHandler(handler)
|
|
119
167
|
|
|
120
168
|
async def _wait_forever(self):
|
|
121
169
|
try:
|
|
@@ -123,6 +171,18 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
123
171
|
except asyncio.CancelledError:
|
|
124
172
|
self.logger.debug("wait_closed cancelled")
|
|
125
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
|
+
|
|
126
186
|
async def close(self) -> None:
|
|
127
187
|
try:
|
|
128
188
|
self.logger.info("Closing client")
|
|
@@ -145,6 +205,23 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
145
205
|
except Exception:
|
|
146
206
|
self.logger.exception("Error closing client")
|
|
147
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
|
+
|
|
148
225
|
async def start(self) -> None:
|
|
149
226
|
"""
|
|
150
227
|
Запускает клиент, подключается к WebSocket, авторизует
|
|
@@ -171,7 +248,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
171
248
|
self.logger.debug("Calling on_start handler")
|
|
172
249
|
result = self._on_start_handler()
|
|
173
250
|
if asyncio.iscoroutine(result):
|
|
174
|
-
await result
|
|
251
|
+
await self._safe_execute(result, context="on_start handler")
|
|
175
252
|
|
|
176
253
|
ping_task = asyncio.create_task(self._send_interactive_ping())
|
|
177
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)
|
|
@@ -9,7 +9,9 @@ from typing_extensions import override
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class BaseFile(ABC):
|
|
12
|
-
def __init__(
|
|
12
|
+
def __init__(
|
|
13
|
+
self, url: str | None = None, path: str | None = None
|
|
14
|
+
) -> None:
|
|
13
15
|
self.url = url
|
|
14
16
|
self.path = path
|
|
15
17
|
|
|
@@ -45,7 +47,9 @@ class Photo(BaseFile):
|
|
|
45
47
|
".bmp",
|
|
46
48
|
} # FIXME: костыль ✅
|
|
47
49
|
|
|
48
|
-
def __init__(
|
|
50
|
+
def __init__(
|
|
51
|
+
self, url: str | None = None, path: str | None = None
|
|
52
|
+
) -> None:
|
|
49
53
|
super().__init__(url, path)
|
|
50
54
|
|
|
51
55
|
def validate_photo(self) -> tuple[str, str] | None:
|
|
@@ -67,7 +71,9 @@ class Photo(BaseFile):
|
|
|
67
71
|
mime_type = mimetypes.guess_type(self.url)[0]
|
|
68
72
|
|
|
69
73
|
if not mime_type or not mime_type.startswith("image/"):
|
|
70
|
-
raise ValueError(
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"URL does not appear to be an image: {self.url}"
|
|
76
|
+
)
|
|
71
77
|
|
|
72
78
|
return (extension[1:], mime_type)
|
|
73
79
|
return None
|
|
@@ -84,7 +90,9 @@ class Video(BaseFile):
|
|
|
84
90
|
|
|
85
91
|
|
|
86
92
|
class File(BaseFile):
|
|
87
|
-
def __init__(
|
|
93
|
+
def __init__(
|
|
94
|
+
self, url: str | None = None, path: str | None = None
|
|
95
|
+
) -> None:
|
|
88
96
|
self.file_name: str = ""
|
|
89
97
|
if path:
|
|
90
98
|
self.file_name = Path(path).name
|
|
@@ -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
|
|
@@ -9,8 +9,6 @@ from typing import TYPE_CHECKING, Any, Literal
|
|
|
9
9
|
|
|
10
10
|
import websockets
|
|
11
11
|
|
|
12
|
-
from pymax.static.constant import DEFAULT_USER_AGENT
|
|
13
|
-
|
|
14
12
|
from .filters import Filter
|
|
15
13
|
from .payloads import UserAgentPayload
|
|
16
14
|
from .static.constant import DEFAULT_TIMEOUT
|
|
@@ -43,6 +41,7 @@ class ClientProtocol(ABC):
|
|
|
43
41
|
self.registration: bool
|
|
44
42
|
self.first_name: str
|
|
45
43
|
self.last_name: str | None
|
|
44
|
+
self._token: str | None
|
|
46
45
|
self._work_dir: str
|
|
47
46
|
self._database_path: Path
|
|
48
47
|
self._ws: websockets.ClientConnection | None = None
|
|
@@ -50,14 +49,15 @@ class ClientProtocol(ABC):
|
|
|
50
49
|
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
51
50
|
self._recv_task: asyncio.Task[Any] | None = None
|
|
52
51
|
self._incoming: asyncio.Queue[dict[str, Any]] | None = None
|
|
53
|
-
self._file_upload_waiters: dict[
|
|
52
|
+
self._file_upload_waiters: dict[
|
|
53
|
+
int, asyncio.Future[dict[str, Any]]
|
|
54
|
+
] = {}
|
|
54
55
|
self.user_agent = UserAgentPayload()
|
|
55
56
|
self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
|
|
56
57
|
self._outgoing_task: asyncio.Task[Any] | None = None
|
|
57
58
|
self._error_count: int = 0
|
|
58
59
|
self._circuit_breaker: bool = False
|
|
59
60
|
self._last_error_time: float = 0.0
|
|
60
|
-
self.user_agent = DEFAULT_USER_AGENT
|
|
61
61
|
self._session_id: int
|
|
62
62
|
self._action_id: int = 0
|
|
63
63
|
self._current_screen: str = "chats_list_tab"
|
|
@@ -70,7 +70,9 @@ class ClientProtocol(ABC):
|
|
|
70
70
|
self._on_message_delete_handlers: list[
|
|
71
71
|
tuple[Callable[[Message], Any], Filter | None]
|
|
72
72
|
] = []
|
|
73
|
-
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None =
|
|
73
|
+
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = (
|
|
74
|
+
None
|
|
75
|
+
)
|
|
74
76
|
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
75
77
|
self._ssl_context: ssl.SSLContext
|
|
76
78
|
self._socket: socket.socket | None = None
|
|
@@ -99,3 +101,9 @@ class ClientProtocol(ABC):
|
|
|
99
101
|
max_retries: int = 3,
|
|
100
102
|
) -> Message | None:
|
|
101
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
|