maxapi-python 1.1.21__tar.gz → 1.2.1__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.21 → maxapi_python-1.2.1}/PKG-INFO +8 -1
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/README.md +6 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/examples/example.py +2 -6
- maxapi_python-1.2.1/examples/test.py +20 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/pyproject.toml +2 -1
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/core.py +14 -152
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/crud.py +1 -1
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/formatting.py +4 -6
- maxapi_python-1.2.1/src/pymax/interfaces.py +256 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/auth.py +162 -20
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/socket.py +30 -43
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/static/constant.py +1 -1
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/static/enum.py +4 -0
- maxapi_python-1.1.21/src/pymax/interfaces.py +0 -122
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.coderabbit.yaml +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.github/FUNDING.yml +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.github/ISSUE_TEMPLATE/refactor.md +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.github/pull_request_template.md +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.github/workflows/publish.yml +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.gitignore +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/.pre-commit-config.yaml +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/LICENSE +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/assets/icon.svg +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/assets/logo.svg +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/examples/flt_test.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/examples/large_file_upload.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/examples/reg.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/examples/telegram_bridge.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/mkdocs.yml +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/pytest.ini +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/Makefile +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/build.sh +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/make.bat +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/_static/logo.svg +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/clients.rst +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/conf.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/decorators.rst +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/examples.rst +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/guides.rst +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/index.rst +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/installation.rst +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/quickstart.rst +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/redocs/source/types.rst +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/ruff.toml +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/__init__.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/exceptions.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/files.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/filters.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/formatter.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/__init__.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/channel.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/group.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/handler.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/message.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/scheduler.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/self.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/telemetry.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/user.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/utils.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/mixins/websocket.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/models.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/navigation.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/payloads.py +0 -0
- {maxapi_python-1.1.21 → maxapi_python-1.2.1}/src/pymax/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: maxapi-python
|
|
3
|
-
Version: 1.1
|
|
3
|
+
Version: 1.2.1
|
|
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
|
|
@@ -16,6 +16,7 @@ Requires-Dist: aiofiles>=24.1.0
|
|
|
16
16
|
Requires-Dist: aiohttp>=3.12.15
|
|
17
17
|
Requires-Dist: lz4>=4.4.4
|
|
18
18
|
Requires-Dist: msgpack>=1.1.1
|
|
19
|
+
Requires-Dist: qrcode>=8.2
|
|
19
20
|
Requires-Dist: sqlmodel>=0.0.24
|
|
20
21
|
Requires-Dist: websockets>=15.0
|
|
21
22
|
Description-Content-Type: text/markdown
|
|
@@ -35,6 +36,12 @@ Description-Content-Type: text/markdown
|
|
|
35
36
|
<img src="https://img.shields.io/badge/packaging-uv-D7FF64.svg" alt="Packaging">
|
|
36
37
|
</p>
|
|
37
38
|
|
|
39
|
+
> [!IMPORTANT]
|
|
40
|
+
> (20.12.25) Из за резкого изменения апи большая часть библиотеки не работает.
|
|
41
|
+
Смотрите [новость](https://t.me/pymax_news/111)
|
|
42
|
+
>
|
|
43
|
+
> P.s добавил логин по qr в dev/1.2.1
|
|
44
|
+
|
|
38
45
|
---
|
|
39
46
|
> ⚠️ **Дисклеймер**
|
|
40
47
|
>
|
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
<img src="https://img.shields.io/badge/packaging-uv-D7FF64.svg" alt="Packaging">
|
|
14
14
|
</p>
|
|
15
15
|
|
|
16
|
+
> [!IMPORTANT]
|
|
17
|
+
> (20.12.25) Из за резкого изменения апи большая часть библиотеки не работает.
|
|
18
|
+
Смотрите [новость](https://t.me/pymax_news/111)
|
|
19
|
+
>
|
|
20
|
+
> P.s добавил логин по qr в dev/1.2.1
|
|
21
|
+
|
|
16
22
|
---
|
|
17
23
|
> ⚠️ **Дисклеймер**
|
|
18
24
|
>
|
|
@@ -44,9 +44,7 @@ async def handle_start() -> None:
|
|
|
44
44
|
messages = []
|
|
45
45
|
from_time = int(time() * 1000)
|
|
46
46
|
while len(messages) < max_messages:
|
|
47
|
-
r = await client.fetch_history(
|
|
48
|
-
chat_id=chat_id, from_time=from_time, backward=30
|
|
49
|
-
)
|
|
47
|
+
r = await client.fetch_history(chat_id=chat_id, from_time=from_time, backward=30)
|
|
50
48
|
if not r:
|
|
51
49
|
break
|
|
52
50
|
from_time = r[0].time
|
|
@@ -131,9 +129,7 @@ async def handle_start() -> None:
|
|
|
131
129
|
|
|
132
130
|
@client.on_message()
|
|
133
131
|
async def handle_message(message: Message) -> None:
|
|
134
|
-
print(
|
|
135
|
-
f"New message in chat {message.chat_id} from {message.sender}: {message.text}"
|
|
136
|
-
)
|
|
132
|
+
print(f"New message in chat {message.chat_id} from {message.sender}: {message.text}")
|
|
137
133
|
# if message.link and message.link.message.attaches:
|
|
138
134
|
# for attach in message.link.message.attaches:
|
|
139
135
|
# print(f"Link attach type: {attach.type}")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from pymax import MaxClient
|
|
4
|
+
from pymax.payloads import UserAgentPayload
|
|
5
|
+
|
|
6
|
+
ua = UserAgentPayload(device_type="WEB")
|
|
7
|
+
|
|
8
|
+
client = MaxClient(
|
|
9
|
+
phone="+79911111111",
|
|
10
|
+
work_dir="cache",
|
|
11
|
+
headers=ua,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@client.on_start
|
|
16
|
+
async def on_start() -> None:
|
|
17
|
+
print(f"MaxClient started as {client.me.names[0].first_name}!")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
asyncio.run(client.start())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "maxapi-python"
|
|
3
|
-
version = "1.1
|
|
3
|
+
version = "1.2.1"
|
|
4
4
|
description = "Python wrapper для API мессенджера Max"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -18,6 +18,7 @@ dependencies = [
|
|
|
18
18
|
"lz4>=4.4.4",
|
|
19
19
|
"aiohttp>=3.12.15",
|
|
20
20
|
"aiofiles>=24.1.0",
|
|
21
|
+
"qrcode>=8.2",
|
|
21
22
|
]
|
|
22
23
|
|
|
23
24
|
[project.urls]
|
|
@@ -6,21 +6,19 @@ import logging
|
|
|
6
6
|
import socket
|
|
7
7
|
import ssl
|
|
8
8
|
import time
|
|
9
|
-
import traceback
|
|
10
9
|
from collections.abc import Awaitable
|
|
11
10
|
from pathlib import Path
|
|
12
11
|
from typing import TYPE_CHECKING, Any, Literal
|
|
13
12
|
from uuid import UUID
|
|
14
13
|
|
|
15
|
-
from typing_extensions import
|
|
14
|
+
from typing_extensions import override
|
|
16
15
|
|
|
17
16
|
from .crud import Database
|
|
18
17
|
from .exceptions import (
|
|
19
18
|
InvalidPhoneError,
|
|
20
19
|
SocketNotConnectedError,
|
|
21
|
-
WebSocketNotConnectedError,
|
|
22
20
|
)
|
|
23
|
-
from .
|
|
21
|
+
from .interfaces import BaseClient
|
|
24
22
|
from .mixins import ApiMixin, SocketMixin, WebSocketMixin
|
|
25
23
|
from .payloads import UserAgentPayload
|
|
26
24
|
from .static.constant import (
|
|
@@ -43,7 +41,7 @@ if TYPE_CHECKING:
|
|
|
43
41
|
logger = logging.getLogger(__name__)
|
|
44
42
|
|
|
45
43
|
|
|
46
|
-
class MaxClient(ApiMixin, WebSocketMixin):
|
|
44
|
+
class MaxClient(ApiMixin, WebSocketMixin, BaseClient):
|
|
47
45
|
"""
|
|
48
46
|
Основной клиент для работы с WebSocket API сервиса Max.
|
|
49
47
|
|
|
@@ -139,9 +137,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
139
137
|
self._circuit_breaker: bool = False
|
|
140
138
|
self._last_error_time: float = 0.0
|
|
141
139
|
|
|
142
|
-
self._device_id = (
|
|
143
|
-
device_id if device_id is not None else self._database.get_device_id()
|
|
144
|
-
)
|
|
140
|
+
self._device_id = device_id if device_id is not None else self._database.get_device_id()
|
|
145
141
|
self._file_upload_waiters: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
146
142
|
|
|
147
143
|
self._token = self._database.get_auth_token() or token
|
|
@@ -161,16 +157,11 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
161
157
|
tuple[Callable[[Message], Any], BaseFilter[Message] | None]
|
|
162
158
|
] = []
|
|
163
159
|
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
164
|
-
self.
|
|
165
|
-
|
|
166
|
-
] = []
|
|
167
|
-
self.
|
|
168
|
-
self.
|
|
169
|
-
Callable[[dict[str, Any]], Any | Awaitable[Any]]
|
|
170
|
-
] = []
|
|
171
|
-
self._scheduled_tasks: list[
|
|
172
|
-
tuple[Callable[[], Any | Awaitable[Any]], float]
|
|
173
|
-
] = []
|
|
160
|
+
self._on_stop_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
161
|
+
self._on_reaction_change_handlers: list[Callable[[str, int, ReactionInfo], Any]] = []
|
|
162
|
+
self._on_chat_update_handlers: list[Callable[[Chat], Any | Awaitable[Any]]] = []
|
|
163
|
+
self._on_raw_receive_handlers: list[Callable[[dict[str, Any]], Any | Awaitable[Any]]] = []
|
|
164
|
+
self._scheduled_tasks: list[tuple[Callable[[], Any | Awaitable[Any]], float]] = []
|
|
174
165
|
|
|
175
166
|
self._ssl_context = ssl.create_default_context()
|
|
176
167
|
self._ssl_context.set_ciphers("DEFAULT")
|
|
@@ -188,36 +179,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
188
179
|
self._work_dir,
|
|
189
180
|
)
|
|
190
181
|
|
|
191
|
-
def _setup_logger(self) -> None:
|
|
192
|
-
if not self.logger.handlers:
|
|
193
|
-
if not self.logger.level:
|
|
194
|
-
self.logger.setLevel(logging.INFO)
|
|
195
|
-
handler = logging.StreamHandler()
|
|
196
|
-
formatter = ColoredFormatter(
|
|
197
|
-
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
198
|
-
datefmt="%Y-%m-%d %H:%M:%S",
|
|
199
|
-
)
|
|
200
|
-
handler.setFormatter(formatter)
|
|
201
|
-
self.logger.addHandler(handler)
|
|
202
|
-
|
|
203
182
|
async def _wait_forever(self) -> None:
|
|
204
183
|
try:
|
|
205
184
|
await self.ws.wait_closed()
|
|
206
185
|
except asyncio.CancelledError:
|
|
207
186
|
self.logger.debug("wait_closed cancelled")
|
|
208
187
|
|
|
209
|
-
async def _safe_execute(self, coro, *, context: str = "unknown") -> Any:
|
|
210
|
-
"""
|
|
211
|
-
Безопасно выполняет пользовательскую корутину.
|
|
212
|
-
Логирует traceback, но не роняет event loop.
|
|
213
|
-
"""
|
|
214
|
-
try:
|
|
215
|
-
return await coro
|
|
216
|
-
except Exception as e:
|
|
217
|
-
self.logger.error(
|
|
218
|
-
f"Unhandled exception in {context}: {e}\n{traceback.format_exc()}"
|
|
219
|
-
)
|
|
220
|
-
|
|
221
188
|
async def close(self) -> None:
|
|
222
189
|
"""
|
|
223
190
|
Закрывает клиент и освобождает ресурсы.
|
|
@@ -245,26 +212,6 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
245
212
|
except Exception:
|
|
246
213
|
self.logger.exception("Error closing client")
|
|
247
214
|
|
|
248
|
-
@override
|
|
249
|
-
def _create_safe_task(
|
|
250
|
-
self, coro: Awaitable[Any], *, name: str | None = None
|
|
251
|
-
) -> asyncio.Task[Any | None]:
|
|
252
|
-
async def runner():
|
|
253
|
-
try:
|
|
254
|
-
return await coro
|
|
255
|
-
except asyncio.CancelledError:
|
|
256
|
-
raise
|
|
257
|
-
except Exception as e:
|
|
258
|
-
tb = traceback.format_exc()
|
|
259
|
-
self.logger.error(
|
|
260
|
-
f"Unhandled exception in task {name or coro}: {e}\n{tb}"
|
|
261
|
-
)
|
|
262
|
-
raise
|
|
263
|
-
|
|
264
|
-
task = asyncio.create_task(runner(), name=name)
|
|
265
|
-
self._background_tasks.add(task)
|
|
266
|
-
return task
|
|
267
|
-
|
|
268
215
|
async def _post_login_tasks(self, sync: bool = True) -> None:
|
|
269
216
|
if sync:
|
|
270
217
|
await self._sync()
|
|
@@ -288,49 +235,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
288
235
|
if asyncio.iscoroutine(result):
|
|
289
236
|
await self._safe_execute(result, context="on_start handler")
|
|
290
237
|
|
|
291
|
-
async def
|
|
292
|
-
for task in list(self._background_tasks):
|
|
293
|
-
task.cancel()
|
|
294
|
-
try:
|
|
295
|
-
await task
|
|
296
|
-
except asyncio.CancelledError:
|
|
297
|
-
pass
|
|
298
|
-
except Exception:
|
|
299
|
-
self.logger.debug(
|
|
300
|
-
"Background task raised during cancellation", exc_info=True
|
|
301
|
-
)
|
|
302
|
-
self._background_tasks.discard(task)
|
|
303
|
-
|
|
304
|
-
if self._recv_task:
|
|
305
|
-
self._recv_task.cancel()
|
|
306
|
-
with contextlib.suppress(asyncio.CancelledError):
|
|
307
|
-
await self._recv_task
|
|
308
|
-
self._recv_task = None
|
|
309
|
-
|
|
310
|
-
if self._outgoing_task:
|
|
311
|
-
self._outgoing_task.cancel()
|
|
312
|
-
with contextlib.suppress(asyncio.CancelledError):
|
|
313
|
-
await self._outgoing_task
|
|
314
|
-
self._outgoing_task = None
|
|
315
|
-
|
|
316
|
-
for fut in self._pending.values():
|
|
317
|
-
if not fut.done():
|
|
318
|
-
fut.set_exception(WebSocketNotConnectedError)
|
|
319
|
-
self._pending.clear()
|
|
320
|
-
|
|
321
|
-
if self._ws:
|
|
322
|
-
try:
|
|
323
|
-
await self._ws.close()
|
|
324
|
-
except Exception:
|
|
325
|
-
self.logger.debug("Error closing ws during cleanup", exc_info=True)
|
|
326
|
-
self._ws = None
|
|
327
|
-
|
|
328
|
-
self.is_connected = False
|
|
329
|
-
self.logger.info("Client start() cleaned up")
|
|
330
|
-
|
|
331
|
-
async def login_with_code(
|
|
332
|
-
self, temp_token: str, code: str, start: bool = False
|
|
333
|
-
) -> None:
|
|
238
|
+
async def login_with_code(self, temp_token: str, code: str, start: bool = False) -> None:
|
|
334
239
|
"""
|
|
335
240
|
Завершает кастомный login flow: отправляет код, сохраняет токен и запускает пост-логин задачи.
|
|
336
241
|
|
|
@@ -348,7 +253,7 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
348
253
|
if not token:
|
|
349
254
|
raise ValueError("Login response did not contain tokenAttrs.LOGIN.token")
|
|
350
255
|
self._token = token
|
|
351
|
-
self._database.update_auth_token(
|
|
256
|
+
self._database.update_auth_token(self._device_id, token)
|
|
352
257
|
if start:
|
|
353
258
|
while True:
|
|
354
259
|
try:
|
|
@@ -385,12 +290,12 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
385
290
|
await self._register(self.first_name, self.last_name)
|
|
386
291
|
|
|
387
292
|
if self._token and self._database.get_auth_token() is None:
|
|
388
|
-
self._database.update_auth_token(
|
|
293
|
+
self._database.update_auth_token(self._device_id, self._token)
|
|
389
294
|
|
|
390
295
|
if self._token is None:
|
|
391
296
|
await self._login()
|
|
392
|
-
|
|
393
|
-
|
|
297
|
+
|
|
298
|
+
await self._sync()
|
|
394
299
|
|
|
395
300
|
await self._post_login_tasks(sync=False)
|
|
396
301
|
|
|
@@ -412,43 +317,6 @@ class MaxClient(ApiMixin, WebSocketMixin):
|
|
|
412
317
|
self.logger.info("Reconnect enabled — restarting client")
|
|
413
318
|
await asyncio.sleep(self.reconnect_delay)
|
|
414
319
|
|
|
415
|
-
async def idle(self):
|
|
416
|
-
"""
|
|
417
|
-
Поддерживает клиента в «ожидающем» состоянии до закрытия клиента или иного прерывающего события.
|
|
418
|
-
|
|
419
|
-
:return: Никогда не возвращает значение; функция блокирует выполнение.
|
|
420
|
-
:rtype: None
|
|
421
|
-
"""
|
|
422
|
-
await asyncio.Event().wait()
|
|
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
|
-
|
|
443
|
-
async def __aenter__(self) -> Self:
|
|
444
|
-
self._create_safe_task(self.start(), name="start")
|
|
445
|
-
while not self.is_connected:
|
|
446
|
-
await asyncio.sleep(0.05)
|
|
447
|
-
return self
|
|
448
|
-
|
|
449
|
-
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
450
|
-
await self.close()
|
|
451
|
-
|
|
452
320
|
|
|
453
321
|
class SocketMaxClient(SocketMixin, MaxClient):
|
|
454
322
|
@override
|
|
@@ -463,12 +331,6 @@ class SocketMaxClient(SocketMixin, MaxClient):
|
|
|
463
331
|
|
|
464
332
|
@override
|
|
465
333
|
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
334
|
for task in list(self._background_tasks):
|
|
473
335
|
task.cancel()
|
|
474
336
|
try:
|
|
@@ -48,7 +48,7 @@ class Database:
|
|
|
48
48
|
session.refresh(auth)
|
|
49
49
|
return auth
|
|
50
50
|
|
|
51
|
-
def update_auth_token(self, device_id:
|
|
51
|
+
def update_auth_token(self, device_id: UUID, token: str) -> None:
|
|
52
52
|
with self.get_session() as session:
|
|
53
53
|
auth = session.exec(select(Auth).where(Auth.device_id == device_id)).first()
|
|
54
54
|
if auth:
|
|
@@ -46,14 +46,12 @@ class Formatting:
|
|
|
46
46
|
|
|
47
47
|
if inner_text is not None and fmt_type is not None:
|
|
48
48
|
next_pos = match.end()
|
|
49
|
-
has_newline = (
|
|
50
|
-
next_pos
|
|
51
|
-
)
|
|
49
|
+
has_newline = (next_pos < len(text) and text[next_pos] == "\n") or (
|
|
50
|
+
next_pos == len(text)
|
|
51
|
+
)
|
|
52
52
|
|
|
53
53
|
length = len(inner_text) + (1 if has_newline else 0)
|
|
54
|
-
elements.append(
|
|
55
|
-
Element(type=fmt_type, from_=current_pos, length=length)
|
|
56
|
-
)
|
|
54
|
+
elements.append(Element(type=fmt_type, from_=current_pos, length=length))
|
|
57
55
|
|
|
58
56
|
clean_parts.append(inner_text)
|
|
59
57
|
if has_newline:
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import logging
|
|
4
|
+
import socket
|
|
5
|
+
import ssl
|
|
6
|
+
import traceback
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from logging import Logger
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
11
|
+
|
|
12
|
+
from typing_extensions import Self
|
|
13
|
+
|
|
14
|
+
from pymax.exceptions import WebSocketNotConnectedError
|
|
15
|
+
from pymax.formatter import ColoredFormatter
|
|
16
|
+
|
|
17
|
+
from .payloads import UserAgentPayload
|
|
18
|
+
from .static.constant import DEFAULT_TIMEOUT
|
|
19
|
+
from .static.enum import Opcode
|
|
20
|
+
from .types import Channel, Chat, Dialog, Me, Message, User
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from uuid import UUID
|
|
25
|
+
|
|
26
|
+
import websockets
|
|
27
|
+
|
|
28
|
+
from pymax import AttachType
|
|
29
|
+
from pymax.types import ReactionInfo
|
|
30
|
+
|
|
31
|
+
from .crud import Database
|
|
32
|
+
from .filters import BaseFilter
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ClientProtocol(ABC):
|
|
36
|
+
def __init__(self, logger: Logger) -> None:
|
|
37
|
+
super().__init__()
|
|
38
|
+
self.logger = logger
|
|
39
|
+
self._users: dict[int, User] = {}
|
|
40
|
+
self.chats: list[Chat] = []
|
|
41
|
+
self._database: Database
|
|
42
|
+
self._device_id: UUID
|
|
43
|
+
self.uri: str
|
|
44
|
+
self.is_connected: bool = False
|
|
45
|
+
self.phone: str
|
|
46
|
+
self.dialogs: list[Dialog] = []
|
|
47
|
+
self.channels: list[Channel] = []
|
|
48
|
+
self.me: Me | None = None
|
|
49
|
+
self.host: str
|
|
50
|
+
self.port: int
|
|
51
|
+
self.proxy: str | Literal[True] | None
|
|
52
|
+
self.registration: bool
|
|
53
|
+
self.first_name: str
|
|
54
|
+
self.last_name: str | None
|
|
55
|
+
self._token: str | None
|
|
56
|
+
self._work_dir: str
|
|
57
|
+
self.reconnect: bool
|
|
58
|
+
self._database_path: Path
|
|
59
|
+
self._ws: websockets.ClientConnection | None = None
|
|
60
|
+
self._seq: int = 0
|
|
61
|
+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
62
|
+
self._recv_task: asyncio.Task[Any] | None = None
|
|
63
|
+
self._incoming: asyncio.Queue[dict[str, Any]] | None = None
|
|
64
|
+
self._file_upload_waiters: dict[
|
|
65
|
+
int,
|
|
66
|
+
asyncio.Future[dict[str, Any]],
|
|
67
|
+
] = {}
|
|
68
|
+
self.user_agent = UserAgentPayload()
|
|
69
|
+
self._outgoing: asyncio.Queue[dict[str, Any]] | None = None
|
|
70
|
+
self._outgoing_task: asyncio.Task[Any] | None = None
|
|
71
|
+
self._error_count: int = 0
|
|
72
|
+
self._circuit_breaker: bool = False
|
|
73
|
+
self._last_error_time: float = 0.0
|
|
74
|
+
self._session_id: int
|
|
75
|
+
self._action_id: int = 0
|
|
76
|
+
self._current_screen: str = "chats_list_tab"
|
|
77
|
+
self._on_message_handlers: list[
|
|
78
|
+
tuple[Callable[[Message], Any], BaseFilter[Message] | None]
|
|
79
|
+
] = []
|
|
80
|
+
self._on_message_edit_handlers: list[
|
|
81
|
+
tuple[Callable[[Message], Any], BaseFilter[Message] | None]
|
|
82
|
+
] = []
|
|
83
|
+
self._on_message_delete_handlers: list[
|
|
84
|
+
tuple[Callable[[Message], Any], BaseFilter[Message] | None]
|
|
85
|
+
] = []
|
|
86
|
+
self._on_reaction_change_handlers: list[Callable[[str, int, ReactionInfo], Any]] = []
|
|
87
|
+
self._on_chat_update_handlers: list[Callable[[Chat], Any | Awaitable[Any]]] = []
|
|
88
|
+
self._on_raw_receive_handlers: list[Callable[[dict[str, Any]], Any | Awaitable[Any]]] = []
|
|
89
|
+
self._scheduled_tasks: list[tuple[Callable[[], Any | Awaitable[Any]], float]] = []
|
|
90
|
+
self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
|
|
91
|
+
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
92
|
+
self._ssl_context: ssl.SSLContext
|
|
93
|
+
self._socket: socket.socket | None = None
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
async def _send_and_wait(
|
|
97
|
+
self,
|
|
98
|
+
opcode: Opcode,
|
|
99
|
+
payload: dict[str, Any],
|
|
100
|
+
cmd: int = 0,
|
|
101
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
102
|
+
) -> dict[str, Any]:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
@abstractmethod
|
|
106
|
+
async def _get_chat(self, chat_id: int) -> Chat | None:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
@abstractmethod
|
|
110
|
+
async def _queue_message(
|
|
111
|
+
self,
|
|
112
|
+
opcode: int,
|
|
113
|
+
payload: dict[str, Any],
|
|
114
|
+
cmd: int = 0,
|
|
115
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
116
|
+
max_retries: int = 3,
|
|
117
|
+
) -> Message | None:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
@abstractmethod
|
|
121
|
+
def _create_safe_task(
|
|
122
|
+
self, coro: Awaitable[Any], name: str | None = None
|
|
123
|
+
) -> asyncio.Task[Any]:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class BaseClient(ClientProtocol):
|
|
128
|
+
def _setup_logger(self) -> None:
|
|
129
|
+
if not self.logger.handlers:
|
|
130
|
+
if not self.logger.level:
|
|
131
|
+
self.logger.setLevel(logging.INFO)
|
|
132
|
+
handler = logging.StreamHandler()
|
|
133
|
+
formatter = ColoredFormatter(
|
|
134
|
+
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
135
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
136
|
+
)
|
|
137
|
+
handler.setFormatter(formatter)
|
|
138
|
+
self.logger.addHandler(handler)
|
|
139
|
+
|
|
140
|
+
async def _safe_execute(self, coro, *, context: str = "unknown") -> Any:
|
|
141
|
+
try:
|
|
142
|
+
return await coro
|
|
143
|
+
except Exception as e:
|
|
144
|
+
self.logger.error(f"Unhandled exception in {context}: {e}\n{traceback.format_exc()}")
|
|
145
|
+
|
|
146
|
+
def _create_safe_task(
|
|
147
|
+
self, coro: Awaitable[Any], name: str | None = None
|
|
148
|
+
) -> asyncio.Task[Any | None]:
|
|
149
|
+
async def runner():
|
|
150
|
+
try:
|
|
151
|
+
return await coro
|
|
152
|
+
except asyncio.CancelledError:
|
|
153
|
+
raise
|
|
154
|
+
except Exception as e:
|
|
155
|
+
tb = traceback.format_exc()
|
|
156
|
+
self.logger.error(f"Unhandled exception in task {name or coro}: {e}\n{tb}")
|
|
157
|
+
raise
|
|
158
|
+
|
|
159
|
+
task = asyncio.create_task(runner(), name=name)
|
|
160
|
+
self._background_tasks.add(task)
|
|
161
|
+
return task
|
|
162
|
+
|
|
163
|
+
async def _cleanup_client(self) -> None:
|
|
164
|
+
for task in list(self._background_tasks):
|
|
165
|
+
task.cancel()
|
|
166
|
+
try:
|
|
167
|
+
await task
|
|
168
|
+
except asyncio.CancelledError:
|
|
169
|
+
pass
|
|
170
|
+
except Exception:
|
|
171
|
+
self.logger.debug("Background task raised during cancellation", exc_info=True)
|
|
172
|
+
self._background_tasks.discard(task)
|
|
173
|
+
|
|
174
|
+
if self._recv_task:
|
|
175
|
+
self._recv_task.cancel()
|
|
176
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
177
|
+
await self._recv_task
|
|
178
|
+
self._recv_task = None
|
|
179
|
+
|
|
180
|
+
if self._outgoing_task:
|
|
181
|
+
self._outgoing_task.cancel()
|
|
182
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
183
|
+
await self._outgoing_task
|
|
184
|
+
self._outgoing_task = None
|
|
185
|
+
|
|
186
|
+
for fut in self._pending.values():
|
|
187
|
+
if not fut.done():
|
|
188
|
+
fut.set_exception(WebSocketNotConnectedError())
|
|
189
|
+
self._pending.clear()
|
|
190
|
+
|
|
191
|
+
if self._ws:
|
|
192
|
+
try:
|
|
193
|
+
await self._ws.close()
|
|
194
|
+
except Exception:
|
|
195
|
+
self.logger.debug("Error closing ws during cleanup", exc_info=True)
|
|
196
|
+
self._ws = None
|
|
197
|
+
|
|
198
|
+
self.is_connected = False
|
|
199
|
+
self.logger.info("Client start() cleaned up")
|
|
200
|
+
|
|
201
|
+
async def idle(self):
|
|
202
|
+
"""
|
|
203
|
+
Поддерживает клиента в «ожидающем» состоянии до закрытия клиента или иного прерывающего события.
|
|
204
|
+
|
|
205
|
+
:return: Никогда не возвращает значение; функция блокирует выполнение.
|
|
206
|
+
:rtype: None
|
|
207
|
+
"""
|
|
208
|
+
await asyncio.Event().wait()
|
|
209
|
+
|
|
210
|
+
def inspect(self) -> None:
|
|
211
|
+
"""
|
|
212
|
+
Выводит в лог текущий статус клиента для отладки.
|
|
213
|
+
"""
|
|
214
|
+
self.logger.info("Pymax")
|
|
215
|
+
self.logger.info("---------")
|
|
216
|
+
self.logger.info(f"Connected: {self.is_connected}")
|
|
217
|
+
if self.me is not None:
|
|
218
|
+
self.logger.info(f"Me: {self.me.names[0].first_name} ({self.me.id})")
|
|
219
|
+
else:
|
|
220
|
+
self.logger.info("Me: N/A")
|
|
221
|
+
self.logger.info(f"Dialogs: {len(self.dialogs)}")
|
|
222
|
+
self.logger.info(f"Chats: {len(self.chats)}")
|
|
223
|
+
self.logger.info(f"Channels: {len(self.channels)}")
|
|
224
|
+
self.logger.info(f"Users cached: {len(self._users)}")
|
|
225
|
+
self.logger.info(f"Background tasks: {len(self._background_tasks)}")
|
|
226
|
+
self.logger.info(f"Scheduled tasks: {len(self._scheduled_tasks)}")
|
|
227
|
+
self.logger.info("---------")
|
|
228
|
+
|
|
229
|
+
async def __aenter__(self) -> Self:
|
|
230
|
+
self._create_safe_task(self.start(), name="start")
|
|
231
|
+
while not self.is_connected:
|
|
232
|
+
await asyncio.sleep(0.05)
|
|
233
|
+
return self
|
|
234
|
+
|
|
235
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
236
|
+
await self.close()
|
|
237
|
+
|
|
238
|
+
@abstractmethod
|
|
239
|
+
async def login_with_code(self, temp_token: str, code: str, start: bool = False) -> None:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
@abstractmethod
|
|
243
|
+
async def _post_login_tasks(self, sync: bool = True) -> None:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
@abstractmethod
|
|
247
|
+
async def _wait_forever(self) -> None:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
@abstractmethod
|
|
251
|
+
async def start(self) -> None:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
@abstractmethod
|
|
255
|
+
async def close(self) -> None:
|
|
256
|
+
pass
|