maxapi-python 1.1.19__py3-none-any.whl → 1.1.21__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-1.1.19.dist-info → maxapi_python-1.1.21.dist-info}/METADATA +37 -54
- maxapi_python-1.1.21.dist-info/RECORD +32 -0
- {maxapi_python-1.1.19.dist-info → maxapi_python-1.1.21.dist-info}/WHEEL +1 -1
- pymax/core.py +167 -55
- pymax/crud.py +4 -8
- pymax/files.py +15 -12
- pymax/filters.py +158 -41
- pymax/formatter.py +1 -0
- pymax/interfaces.py +20 -10
- pymax/mixins/__init__.py +3 -0
- pymax/mixins/auth.py +75 -18
- pymax/mixins/channel.py +41 -42
- pymax/mixins/group.py +182 -3
- pymax/mixins/handler.py +164 -39
- pymax/mixins/message.py +311 -80
- pymax/mixins/scheduler.py +28 -0
- pymax/mixins/self.py +162 -9
- pymax/mixins/socket.py +267 -254
- pymax/mixins/user.py +63 -42
- pymax/mixins/websocket.py +145 -136
- pymax/payloads.py +48 -3
- pymax/static/constant.py +4 -2
- pymax/static/enum.py +1 -1
- pymax/types.py +114 -2
- maxapi_python-1.1.19.dist-info/RECORD +0 -31
- {maxapi_python-1.1.19.dist-info → maxapi_python-1.1.21.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: maxapi-python
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.21
|
|
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
|
|
@@ -79,65 +79,47 @@ uv add -U maxapi-python
|
|
|
79
79
|
|
|
80
80
|
```python
|
|
81
81
|
import asyncio
|
|
82
|
+
|
|
82
83
|
from pymax import MaxClient, Message
|
|
84
|
+
from pymax.filters import Filters
|
|
85
|
+
|
|
86
|
+
client = MaxClient(
|
|
87
|
+
phone="+1234567890",
|
|
88
|
+
work_dir="cache", # директория для сессий
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Обработка входящих сообщений
|
|
93
|
+
@client.on_message(Filters.chat(0)) # фильтр по ID чата
|
|
94
|
+
async def on_message(msg: Message) -> None:
|
|
95
|
+
print(f"[{msg.sender}] {msg.text}")
|
|
83
96
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
97
|
+
await client.send_message(
|
|
98
|
+
chat_id=msg.chat_id,
|
|
99
|
+
text="Привет, я бот на PyMax!",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
await client.add_reaction(
|
|
103
|
+
chat_id=msg.chat_id,
|
|
104
|
+
message_id=str(msg.id),
|
|
105
|
+
reaction="👍",
|
|
106
|
+
)
|
|
87
107
|
|
|
88
|
-
# Обработчик входящих сообщений
|
|
89
|
-
@client.on_message()
|
|
90
|
-
async def handle_message(message: Message) -> None:
|
|
91
|
-
print(f"{message.sender}: {message.text}")
|
|
92
108
|
|
|
93
|
-
# Обработчик запуска клиента
|
|
94
109
|
@client.on_start
|
|
95
|
-
async def
|
|
96
|
-
print("Клиент
|
|
110
|
+
async def on_start() -> None:
|
|
111
|
+
print(f"Клиент запущен. Ваш ID: {client.me.id}")
|
|
97
112
|
|
|
98
|
-
# Получение истории
|
|
113
|
+
# Получение истории
|
|
99
114
|
history = await client.fetch_history(chat_id=0)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
# Работа с чатами
|
|
110
|
-
for chat in client.chats:
|
|
111
|
-
print(f"Чат: {chat.title}")
|
|
112
|
-
|
|
113
|
-
# Отправка сообщения
|
|
114
|
-
message = await client.send_message(
|
|
115
|
-
"Привет от PyMax!",
|
|
116
|
-
chat.id,
|
|
117
|
-
notify=True
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
# Редактирование сообщения
|
|
121
|
-
await asyncio.sleep(2)
|
|
122
|
-
await client.edit_message(
|
|
123
|
-
chat.id,
|
|
124
|
-
message.id,
|
|
125
|
-
"Привет от PyMax! (отредактировано)"
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
# Удаление сообщения
|
|
129
|
-
await asyncio.sleep(2)
|
|
130
|
-
await client.delete_message(chat.id, [message.id], for_me=False)
|
|
131
|
-
|
|
132
|
-
# Работа с диалогами
|
|
133
|
-
for dialog in client.dialogs:
|
|
134
|
-
print(f"Диалог: {dialog.last_message.text}")
|
|
135
|
-
|
|
136
|
-
# Работа с каналами
|
|
137
|
-
for channel in client.channels:
|
|
138
|
-
print(f"Канал: {channel.title}")
|
|
139
|
-
|
|
140
|
-
await client.close()
|
|
115
|
+
print("Последние сообщения из чата 0:")
|
|
116
|
+
for m in history:
|
|
117
|
+
print(f"- {m.text}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def main():
|
|
121
|
+
await client.start() # подключение и авторизация
|
|
122
|
+
|
|
141
123
|
|
|
142
124
|
if __name__ == "__main__":
|
|
143
125
|
asyncio.run(main())
|
|
@@ -145,7 +127,8 @@ if __name__ == "__main__":
|
|
|
145
127
|
|
|
146
128
|
## Документация
|
|
147
129
|
|
|
148
|
-
[
|
|
130
|
+
[GitHub Pages](https://maxapiteam.github.io/PyMax/)
|
|
131
|
+
[DeepWiki](https://deepwiki.com/MaxApiTeam/PyMax)
|
|
149
132
|
|
|
150
133
|
## Лицензия
|
|
151
134
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
pymax/__init__.py,sha256=6wUKKwsyxFpWG3b7kwptOvHd-w78C-ygw42iCDBYQvc,1915
|
|
2
|
+
pymax/core.py,sha256=aF-nGQG92KV9ZBIXWTxl2mpyfXgmZ2OQq2-OnouB2O4,19720
|
|
3
|
+
pymax/crud.py,sha256=uphxDTCj1tGCrQ1lE2osLIZY7WLWbS-pkG46i2pU8Z0,3075
|
|
4
|
+
pymax/exceptions.py,sha256=nDUNx7bM-Yjugj-qfIllcrnwLg9JpZroYqfXapjYbMQ,3178
|
|
5
|
+
pymax/files.py,sha256=AvFIr34Desq2p4CNWXIngRqeyTBKMT98VmcnI-zvUU0,3462
|
|
6
|
+
pymax/filters.py,sha256=gSHPJ1Vi37HKPxf0jRRv9Q3iGwhiQjw1MGrCaouqHzs,4325
|
|
7
|
+
pymax/formatter.py,sha256=RJ_5VbY7Li8UM3xL1AvcXo8v1iYnY8GvDDkreaFqtnY,860
|
|
8
|
+
pymax/formatting.py,sha256=hhtmakfcQDzQRsAckPunnJOCKY2lFdLeIp8Yw53yY8s,2522
|
|
9
|
+
pymax/interfaces.py,sha256=2wdS5BfguU9zH3yLSGBWtSq0_SWwcSVLHSd1Z4ZIS2g,4003
|
|
10
|
+
pymax/models.py,sha256=PsPGbOkERxesZZltjNrmqhOfRcO44Is2ThbEToREcB8,201
|
|
11
|
+
pymax/navigation.py,sha256=4ia6RGY2pXMArboNhHkbWlWX7LtcYK1VGVXorPX0Pb4,5747
|
|
12
|
+
pymax/payloads.py,sha256=-GEJVXXlmJiFSTX4ToVNzmSZSrvSRe-BLOwYyRxGkWY,7280
|
|
13
|
+
pymax/types.py,sha256=_ARcVXLGHyiGAJKYPd6EU9QDKzz4VwS6kjTu3YEH_u4,35523
|
|
14
|
+
pymax/mixins/__init__.py,sha256=5sXJME34S1EssuDETaN4DLRH7vhMw_Q3Jmay9myAIZM,775
|
|
15
|
+
pymax/mixins/auth.py,sha256=zErX2vItVwHV8rAKWXX74dEEYSlECr2oYmnzEzw86eM,9661
|
|
16
|
+
pymax/mixins/channel.py,sha256=W52YnBay1sUYXxF9oAWsz44ZUh_s45jSvKmAyjTbULM,5357
|
|
17
|
+
pymax/mixins/group.py,sha256=LqI1QHmZlmtuQ0-4H1MrNeBV-O9SMDMfHT9f4B_2poE,15189
|
|
18
|
+
pymax/mixins/handler.py,sha256=ETnI8fA386LYJGjWtUhhWzQHREUA78di1aO1oWwtscA,12523
|
|
19
|
+
pymax/mixins/message.py,sha256=AznKKmTMxdzsYl8IecT43RjWpGvlQM85GzSNGFbI8BA,33279
|
|
20
|
+
pymax/mixins/scheduler.py,sha256=rcMfgfZnzu5V6MkcCg6uRgbi-jkc7UyqOjemulydWbc,964
|
|
21
|
+
pymax/mixins/self.py,sha256=Be5L64eNYylGM-NmoxFpQZv1ohsC1Dx_Cs3Om__V96s,6976
|
|
22
|
+
pymax/mixins/socket.py,sha256=9_TtTzB1mXH2U-odtwvOEXViUOCzPXBgTEXbMckhiq4,23122
|
|
23
|
+
pymax/mixins/telemetry.py,sha256=LWr68DNQkPhAjGRDYQ5lORXxC3Yw6M9E8sF0TCNISTE,3609
|
|
24
|
+
pymax/mixins/user.py,sha256=RSZd4t-aq8P2k3cVzNVWBkUf-_xTWILrBzwxLRgk1pw,9450
|
|
25
|
+
pymax/mixins/utils.py,sha256=s3FUf3i_wjn2Gbg5YY1rWZB-90ZEGrrcUuND_MqqSTE,853
|
|
26
|
+
pymax/mixins/websocket.py,sha256=m2swhSHIcFG6iABAik_oWxIpHfr0sxZ74I6VRU-iVO8,17809
|
|
27
|
+
pymax/static/constant.py,sha256=BjSb2G6CTN6Idpo5GeLa-07YodnLNycYDmI7nOk0YGs,1111
|
|
28
|
+
pymax/static/enum.py,sha256=Lf_qHbA2e2oK7X2uuiXInvaV1ql6hX-4wypFnokaazM,4584
|
|
29
|
+
maxapi_python-1.1.21.dist-info/METADATA,sha256=_VPALryggwzxWHAcYKsKSb4rP094y1PmLEFKIOFolRs,5560
|
|
30
|
+
maxapi_python-1.1.21.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
31
|
+
maxapi_python-1.1.21.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
|
|
32
|
+
maxapi_python-1.1.21.dist-info/RECORD,,
|
pymax/core.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import asyncio
|
|
2
4
|
import contextlib
|
|
3
5
|
import logging
|
|
@@ -7,12 +9,17 @@ import time
|
|
|
7
9
|
import traceback
|
|
8
10
|
from collections.abc import Awaitable
|
|
9
11
|
from pathlib import Path
|
|
10
|
-
from typing import TYPE_CHECKING, Any, Literal
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
13
|
+
from uuid import UUID
|
|
11
14
|
|
|
12
|
-
from typing_extensions import override
|
|
15
|
+
from typing_extensions import Self, override
|
|
13
16
|
|
|
14
17
|
from .crud import Database
|
|
15
|
-
from .exceptions import
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
InvalidPhoneError,
|
|
20
|
+
SocketNotConnectedError,
|
|
21
|
+
WebSocketNotConnectedError,
|
|
22
|
+
)
|
|
16
23
|
from .formatter import ColoredFormatter
|
|
17
24
|
from .mixins import ApiMixin, SocketMixin, WebSocketMixin
|
|
18
25
|
from .payloads import UserAgentPayload
|
|
@@ -23,12 +30,13 @@ from .static.constant import (
|
|
|
23
30
|
)
|
|
24
31
|
|
|
25
32
|
if TYPE_CHECKING:
|
|
26
|
-
from collections.abc import
|
|
27
|
-
from typing import Any
|
|
33
|
+
from collections.abc import Callable
|
|
28
34
|
|
|
29
35
|
import websockets
|
|
30
36
|
|
|
31
|
-
from .filters import
|
|
37
|
+
from pymax.filters import BaseFilter
|
|
38
|
+
|
|
39
|
+
from .filters import Filters
|
|
32
40
|
from .types import Channel, Chat, Dialog, Me, Message, ReactionInfo, User
|
|
33
41
|
|
|
34
42
|
|
|
@@ -39,29 +47,36 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
39
47
|
"""
|
|
40
48
|
Основной клиент для работы с WebSocket API сервиса Max.
|
|
41
49
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
50
|
+
:param phone: Номер телефона для авторизации.
|
|
51
|
+
:type phone: str
|
|
52
|
+
:param uri: URI WebSocket сервера.
|
|
53
|
+
:type uri: str, optional
|
|
54
|
+
:param work_dir: Рабочая директория для хранения базы данных.
|
|
55
|
+
:type work_dir: str, optional
|
|
56
|
+
:param logger: Пользовательский логгер. Если не передан, используется логгер модуля с именем f"{__name__}.MaxClient".
|
|
57
|
+
:type logger: logging.Logger | None
|
|
58
|
+
:param headers: Заголовки для подключения к WebSocket.
|
|
59
|
+
:type headers: UserAgentPayload
|
|
60
|
+
:param token: Токен авторизации. Если не передан, будет выполнен процесс логина по номеру телефона.
|
|
61
|
+
:type token: str | None, optional
|
|
62
|
+
:param host: Хост API сервера.
|
|
63
|
+
:type host: str, optional
|
|
64
|
+
:param port: Порт API сервера.
|
|
65
|
+
:type port: int, optional
|
|
66
|
+
:param registration: Флаг регистрации нового пользователя.
|
|
67
|
+
:type registration: bool, optional
|
|
68
|
+
:param first_name: Имя пользователя для регистрации. Требуется, если registration=True.
|
|
69
|
+
:type first_name: str, optional
|
|
70
|
+
:param last_name: Фамилия пользователя для регистрации.
|
|
71
|
+
:type last_name: str | None, optional
|
|
72
|
+
:param send_fake_telemetry: Флаг отправки фейковой телеметрии.
|
|
73
|
+
:type send_fake_telemetry: bool, optional
|
|
74
|
+
:param proxy: Прокси для подключения к WebSocket (см. https://websockets.readthedocs.io/en/stable/topics/proxies.html).
|
|
75
|
+
:type proxy: str | Literal[True] | None, optional
|
|
76
|
+
:param reconnect: Флаг автоматического переподключения при потере соединения.
|
|
77
|
+
:type reconnect: bool, optional
|
|
78
|
+
|
|
79
|
+
:raises InvalidPhoneError: Если формат номера телефона неверный.
|
|
65
80
|
"""
|
|
66
81
|
|
|
67
82
|
def __init__(
|
|
@@ -78,6 +93,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
78
93
|
registration: bool = False,
|
|
79
94
|
first_name: str = "",
|
|
80
95
|
last_name: str | None = None,
|
|
96
|
+
device_id: UUID | None = None,
|
|
81
97
|
logger: logging.Logger | None = None,
|
|
82
98
|
reconnect: bool = True,
|
|
83
99
|
reconnect_delay: float = 1.0,
|
|
@@ -123,7 +139,9 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
123
139
|
self._circuit_breaker: bool = False
|
|
124
140
|
self._last_error_time: float = 0.0
|
|
125
141
|
|
|
126
|
-
self._device_id =
|
|
142
|
+
self._device_id = (
|
|
143
|
+
device_id if device_id is not None else self._database.get_device_id()
|
|
144
|
+
)
|
|
127
145
|
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
128
146
|
|
|
129
147
|
self._token = self._database.get_auth_token() or token
|
|
@@ -134,19 +152,25 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
134
152
|
self._current_screen: str = "chats_list_tab"
|
|
135
153
|
|
|
136
154
|
self._on_message_handlers: list[
|
|
137
|
-
tuple[Callable[[Message], Any],
|
|
155
|
+
tuple[Callable[[Message], Any], BaseFilter[Message] | None]
|
|
138
156
|
] = []
|
|
139
157
|
self._on_message_edit_handlers: list[
|
|
140
|
-
tuple[Callable[[Message], Any],
|
|
158
|
+
tuple[Callable[[Message], Any], BaseFilter[Message] | None]
|
|
141
159
|
] = []
|
|
142
160
|
self._on_message_delete_handlers: list[
|
|
143
|
-
tuple[Callable[[Message], Any],
|
|
161
|
+
tuple[Callable[[Message], Any], BaseFilter[Message] | None]
|
|
144
162
|
] = []
|
|
145
163
|
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
146
164
|
self._on_reaction_change_handlers: list[
|
|
147
165
|
tuple[Callable[[str, int, ReactionInfo], Any]]
|
|
148
166
|
] = []
|
|
149
167
|
self._on_chat_update_handlers: list[tuple[Callable[[Chat], Any]]] = []
|
|
168
|
+
self._on_raw_receive_handlers: list[
|
|
169
|
+
Callable[[dict[str, Any]], Any | Awaitable[Any]]
|
|
170
|
+
] = []
|
|
171
|
+
self._scheduled_tasks: list[
|
|
172
|
+
tuple[Callable[[], Any | Awaitable[Any]], float]
|
|
173
|
+
] = []
|
|
150
174
|
|
|
151
175
|
self._ssl_context = ssl.create_default_context()
|
|
152
176
|
self._ssl_context.set_ciphers("DEFAULT")
|
|
@@ -176,13 +200,13 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
176
200
|
handler.setFormatter(formatter)
|
|
177
201
|
self.logger.addHandler(handler)
|
|
178
202
|
|
|
179
|
-
async def _wait_forever(self):
|
|
203
|
+
async def _wait_forever(self) -> None:
|
|
180
204
|
try:
|
|
181
205
|
await self.ws.wait_closed()
|
|
182
206
|
except asyncio.CancelledError:
|
|
183
207
|
self.logger.debug("wait_closed cancelled")
|
|
184
208
|
|
|
185
|
-
async def _safe_execute(self, coro, *, context: str = "unknown"):
|
|
209
|
+
async def _safe_execute(self, coro, *, context: str = "unknown") -> Any:
|
|
186
210
|
"""
|
|
187
211
|
Безопасно выполняет пользовательскую корутину.
|
|
188
212
|
Логирует traceback, но не роняет event loop.
|
|
@@ -195,6 +219,11 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
195
219
|
)
|
|
196
220
|
|
|
197
221
|
async def close(self) -> None:
|
|
222
|
+
"""
|
|
223
|
+
Закрывает клиент и освобождает ресурсы.
|
|
224
|
+
|
|
225
|
+
:return: None
|
|
226
|
+
"""
|
|
198
227
|
try:
|
|
199
228
|
self.logger.info("Closing client")
|
|
200
229
|
if self._recv_task:
|
|
@@ -226,11 +255,11 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
226
255
|
except asyncio.CancelledError:
|
|
227
256
|
raise
|
|
228
257
|
except Exception as e:
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
258
|
+
tb = traceback.format_exc()
|
|
259
|
+
self.logger.error(
|
|
260
|
+
f"Unhandled exception in task {name or coro}: {e}\n{tb}"
|
|
232
261
|
)
|
|
233
|
-
|
|
262
|
+
raise
|
|
234
263
|
|
|
235
264
|
task = asyncio.create_task(runner(), name=name)
|
|
236
265
|
self._background_tasks.add(task)
|
|
@@ -240,22 +269,26 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
240
269
|
if sync:
|
|
241
270
|
await self._sync()
|
|
242
271
|
|
|
243
|
-
|
|
244
|
-
self.logger.debug("Calling on_start handler")
|
|
245
|
-
result = self._on_start_handler()
|
|
246
|
-
if asyncio.iscoroutine(result):
|
|
247
|
-
await self._safe_execute(result, context="on_start handler")
|
|
248
|
-
|
|
272
|
+
self.logger.debug("is_connected=%s before starting ping", self.is_connected)
|
|
249
273
|
ping_task = asyncio.create_task(self._send_interactive_ping())
|
|
250
274
|
ping_task.add_done_callback(self._log_task_exception)
|
|
251
275
|
self._background_tasks.add(ping_task)
|
|
252
276
|
|
|
277
|
+
start_scheduled_task = asyncio.create_task(self._start_scheduled_tasks())
|
|
278
|
+
start_scheduled_task.add_done_callback(self._log_task_exception)
|
|
279
|
+
|
|
253
280
|
if self._send_fake_telemetry:
|
|
254
281
|
telemetry_task = asyncio.create_task(self._start())
|
|
255
282
|
telemetry_task.add_done_callback(self._log_task_exception)
|
|
256
283
|
self._background_tasks.add(telemetry_task)
|
|
257
284
|
|
|
258
|
-
|
|
285
|
+
if self._on_start_handler:
|
|
286
|
+
self.logger.debug("Calling on_start handler")
|
|
287
|
+
result = self._on_start_handler()
|
|
288
|
+
if asyncio.iscoroutine(result):
|
|
289
|
+
await self._safe_execute(result, context="on_start handler")
|
|
290
|
+
|
|
291
|
+
async def _cleanup_client(self) -> None:
|
|
259
292
|
for task in list(self._background_tasks):
|
|
260
293
|
task.cancel()
|
|
261
294
|
try:
|
|
@@ -301,17 +334,21 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
301
334
|
"""
|
|
302
335
|
Завершает кастомный login flow: отправляет код, сохраняет токен и запускает пост-логин задачи.
|
|
303
336
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
337
|
+
:param temp_token: Временный токен, полученный из request_code.
|
|
338
|
+
:type temp_token: str
|
|
339
|
+
:param code: Код верификации (6 цифр).
|
|
340
|
+
:type code: str
|
|
341
|
+
:param start: Флаг запуска пост-логин задач и ожидания навсегда. Если False, только сохраняет токен.
|
|
342
|
+
:type start: bool, optional
|
|
343
|
+
:return: None
|
|
344
|
+
:rtype: None
|
|
310
345
|
"""
|
|
311
346
|
resp = await self._send_code(code, temp_token)
|
|
312
347
|
token = resp.get("tokenAttrs", {}).get("LOGIN", {}).get("token")
|
|
348
|
+
if not token:
|
|
349
|
+
raise ValueError("Login response did not contain tokenAttrs.LOGIN.token")
|
|
313
350
|
self._token = token
|
|
314
|
-
self._database.update_auth_token(self._device_id, token)
|
|
351
|
+
self._database.update_auth_token(str(self._device_id), token)
|
|
315
352
|
if start:
|
|
316
353
|
while True:
|
|
317
354
|
try:
|
|
@@ -332,6 +369,9 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
332
369
|
Запускает клиент, подключается к WebSocket, авторизует
|
|
333
370
|
пользователя (если нужно) и запускает фоновый цикл.
|
|
334
371
|
Теперь включает безопасный reconnect-loop, если self.reconnect=True.
|
|
372
|
+
|
|
373
|
+
:return: None
|
|
374
|
+
:rtype: None
|
|
335
375
|
"""
|
|
336
376
|
|
|
337
377
|
while True:
|
|
@@ -345,7 +385,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
345
385
|
await self._register(self.first_name, self.last_name)
|
|
346
386
|
|
|
347
387
|
if self._token and self._database.get_auth_token() is None:
|
|
348
|
-
self._database.update_auth_token(self._device_id, self._token)
|
|
388
|
+
self._database.update_auth_token(str(self._device_id), self._token)
|
|
349
389
|
|
|
350
390
|
if self._token is None:
|
|
351
391
|
await self._login()
|
|
@@ -356,7 +396,6 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
356
396
|
|
|
357
397
|
await self._wait_forever()
|
|
358
398
|
self.logger.info("WebSocket closed (wait_forever exited)")
|
|
359
|
-
|
|
360
399
|
except Exception as e:
|
|
361
400
|
self.logger.exception("Client start iteration failed")
|
|
362
401
|
raise e
|
|
@@ -374,8 +413,33 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
374
413
|
await asyncio.sleep(self.reconnect_delay)
|
|
375
414
|
|
|
376
415
|
async def idle(self):
|
|
416
|
+
"""
|
|
417
|
+
Поддерживает клиента в «ожидающем» состоянии до закрытия клиента или иного прерывающего события.
|
|
418
|
+
|
|
419
|
+
:return: Никогда не возвращает значение; функция блокирует выполнение.
|
|
420
|
+
:rtype: None
|
|
421
|
+
"""
|
|
377
422
|
await asyncio.Event().wait()
|
|
378
423
|
|
|
424
|
+
def inspect(self) -> None:
|
|
425
|
+
"""
|
|
426
|
+
Выводит в лог текущий статус клиента для отладки.
|
|
427
|
+
"""
|
|
428
|
+
self.logger.info("Pymax")
|
|
429
|
+
self.logger.info("---------")
|
|
430
|
+
self.logger.info(f"Connected: {self.is_connected}")
|
|
431
|
+
if self.me is not None:
|
|
432
|
+
self.logger.info(f"Me: {self.me.names[0].first_name} ({self.me.id})")
|
|
433
|
+
else:
|
|
434
|
+
self.logger.info("Me: N/A")
|
|
435
|
+
self.logger.info(f"Dialogs: {len(self.dialogs)}")
|
|
436
|
+
self.logger.info(f"Chats: {len(self.chats)}")
|
|
437
|
+
self.logger.info(f"Channels: {len(self.channels)}")
|
|
438
|
+
self.logger.info(f"Users cached: {len(self._users)}")
|
|
439
|
+
self.logger.info(f"Background tasks: {len(self._background_tasks)}")
|
|
440
|
+
self.logger.info(f"Scheduled tasks: {len(self._scheduled_tasks)}")
|
|
441
|
+
self.logger.info("---------")
|
|
442
|
+
|
|
379
443
|
async def __aenter__(self) -> Self:
|
|
380
444
|
self._create_safe_task(self.start(), name="start")
|
|
381
445
|
while not self.is_connected:
|
|
@@ -396,3 +460,51 @@ class SocketMaxClient(SocketMixin, MaxClient):
|
|
|
396
460
|
self.logger.debug("Socket recv_task cancelled")
|
|
397
461
|
except Exception as e:
|
|
398
462
|
self.logger.exception("Socket recv_task failed: %s", e)
|
|
463
|
+
|
|
464
|
+
@override
|
|
465
|
+
async def _cleanup_client(self):
|
|
466
|
+
"""
|
|
467
|
+
Socket-specific cleanup: cancel background tasks, set pending futures
|
|
468
|
+
exceptions to SocketNotConnectedError, and close socket.
|
|
469
|
+
"""
|
|
470
|
+
from .exceptions import SocketNotConnectedError
|
|
471
|
+
|
|
472
|
+
for task in list(self._background_tasks):
|
|
473
|
+
task.cancel()
|
|
474
|
+
try:
|
|
475
|
+
await task
|
|
476
|
+
except asyncio.CancelledError:
|
|
477
|
+
pass
|
|
478
|
+
except Exception:
|
|
479
|
+
self.logger.debug(
|
|
480
|
+
"Background task raised during cancellation (socket)",
|
|
481
|
+
exc_info=True,
|
|
482
|
+
)
|
|
483
|
+
self._background_tasks.discard(task)
|
|
484
|
+
|
|
485
|
+
if self._recv_task:
|
|
486
|
+
self._recv_task.cancel()
|
|
487
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
488
|
+
await self._recv_task
|
|
489
|
+
self._recv_task = None
|
|
490
|
+
|
|
491
|
+
if self._outgoing_task:
|
|
492
|
+
self._outgoing_task.cancel()
|
|
493
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
494
|
+
await self._outgoing_task
|
|
495
|
+
self._outgoing_task = None
|
|
496
|
+
|
|
497
|
+
for fut in self._pending.values():
|
|
498
|
+
if not fut.done():
|
|
499
|
+
fut.set_exception(SocketNotConnectedError())
|
|
500
|
+
self._pending.clear()
|
|
501
|
+
|
|
502
|
+
if self._socket:
|
|
503
|
+
try:
|
|
504
|
+
self._socket.close()
|
|
505
|
+
except Exception:
|
|
506
|
+
self.logger.debug("Error closing socket during cleanup", exc_info=True)
|
|
507
|
+
self._socket = None
|
|
508
|
+
|
|
509
|
+
self.is_connected = False
|
|
510
|
+
self.logger.info("Client start() cleaned up (socket)")
|
pymax/crud.py
CHANGED
|
@@ -31,9 +31,8 @@ class Database:
|
|
|
31
31
|
|
|
32
32
|
def get_device_id(self) -> UUID:
|
|
33
33
|
with self.get_session() as session:
|
|
34
|
-
device_id =
|
|
35
|
-
|
|
36
|
-
)
|
|
34
|
+
device_id = session.exec(select(Auth.device_id)).first()
|
|
35
|
+
|
|
37
36
|
if device_id is None:
|
|
38
37
|
auth = Auth()
|
|
39
38
|
session.add(auth)
|
|
@@ -49,11 +48,9 @@ class Database:
|
|
|
49
48
|
session.refresh(auth)
|
|
50
49
|
return auth
|
|
51
50
|
|
|
52
|
-
def update_auth_token(self, device_id:
|
|
51
|
+
def update_auth_token(self, device_id: str, token: str) -> None:
|
|
53
52
|
with self.get_session() as session:
|
|
54
|
-
auth = session.exec(
|
|
55
|
-
select(Auth).where(Auth.device_id == device_id)
|
|
56
|
-
).first()
|
|
53
|
+
auth = session.exec(select(Auth).where(Auth.device_id == device_id)).first()
|
|
57
54
|
if auth:
|
|
58
55
|
auth.token = token
|
|
59
56
|
session.add(auth)
|
|
@@ -86,7 +83,6 @@ class Database:
|
|
|
86
83
|
with self.get_session() as session:
|
|
87
84
|
rows = session.exec(select(Auth)).all()
|
|
88
85
|
if not rows:
|
|
89
|
-
# Create default Auth with device type from enum
|
|
90
86
|
auth = Auth(device_type=DeviceType.WEB.value)
|
|
91
87
|
session.add(auth)
|
|
92
88
|
session.commit()
|
pymax/files.py
CHANGED
|
@@ -9,9 +9,7 @@ from typing_extensions import override
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class BaseFile(ABC):
|
|
12
|
-
def __init__(
|
|
13
|
-
self, url: str | None = None, path: str | None = None
|
|
14
|
-
) -> None:
|
|
12
|
+
def __init__(self, url: str | None = None, path: str | None = None) -> None:
|
|
15
13
|
self.url = url
|
|
16
14
|
self.path = path
|
|
17
15
|
|
|
@@ -47,9 +45,7 @@ class Photo(BaseFile):
|
|
|
47
45
|
".bmp",
|
|
48
46
|
} # FIXME: костыль ✅
|
|
49
47
|
|
|
50
|
-
def __init__(
|
|
51
|
-
self, url: str | None = None, path: str | None = None
|
|
52
|
-
) -> None:
|
|
48
|
+
def __init__(self, url: str | None = None, path: str | None = None) -> None:
|
|
53
49
|
super().__init__(url, path)
|
|
54
50
|
|
|
55
51
|
def validate_photo(self) -> tuple[str, str] | None:
|
|
@@ -71,9 +67,7 @@ class Photo(BaseFile):
|
|
|
71
67
|
mime_type = mimetypes.guess_type(self.url)[0]
|
|
72
68
|
|
|
73
69
|
if not mime_type or not mime_type.startswith("image/"):
|
|
74
|
-
raise ValueError(
|
|
75
|
-
f"URL does not appear to be an image: {self.url}"
|
|
76
|
-
)
|
|
70
|
+
raise ValueError(f"URL does not appear to be an image: {self.url}")
|
|
77
71
|
|
|
78
72
|
return (extension[1:], mime_type)
|
|
79
73
|
return None
|
|
@@ -84,15 +78,24 @@ class Photo(BaseFile):
|
|
|
84
78
|
|
|
85
79
|
|
|
86
80
|
class Video(BaseFile):
|
|
81
|
+
def __init__(self, url: str | None = None, path: str | None = None) -> None:
|
|
82
|
+
self.file_name: str = ""
|
|
83
|
+
if path:
|
|
84
|
+
self.file_name = Path(path).name
|
|
85
|
+
elif url:
|
|
86
|
+
self.file_name = Path(url).name
|
|
87
|
+
|
|
88
|
+
if not self.file_name:
|
|
89
|
+
raise ValueError("Either url or path must be provided.")
|
|
90
|
+
super().__init__(url, path)
|
|
91
|
+
|
|
87
92
|
@override
|
|
88
93
|
async def read(self) -> bytes:
|
|
89
94
|
return await super().read()
|
|
90
95
|
|
|
91
96
|
|
|
92
97
|
class File(BaseFile):
|
|
93
|
-
def __init__(
|
|
94
|
-
self, url: str | None = None, path: str | None = None
|
|
95
|
-
) -> None:
|
|
98
|
+
def __init__(self, url: str | None = None, path: str | None = None) -> None:
|
|
96
99
|
self.file_name: str = ""
|
|
97
100
|
if path:
|
|
98
101
|
self.file_name = Path(path).name
|