maxapi-python 2.0.1__py3-none-any.whl → 2.1.0__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-2.0.1.dist-info → maxapi_python-2.1.0.dist-info}/METADATA +4 -1
- {maxapi_python-2.0.1.dist-info → maxapi_python-2.1.0.dist-info}/RECORD +61 -54
- pymax/__init__.py +1 -1
- pymax/api/auth/enums.py +13 -2
- pymax/api/auth/payloads.py +10 -6
- pymax/api/auth/service.py +75 -12
- pymax/api/bots/__init__.py +1 -0
- pymax/api/bots/payloads.py +7 -0
- pymax/api/bots/service.py +35 -0
- pymax/api/chats/enums.py +1 -0
- pymax/api/chats/payloads.py +14 -0
- pymax/api/chats/service.py +112 -12
- pymax/api/facade.py +2 -0
- pymax/api/messages/payloads.py +5 -1
- pymax/api/messages/service.py +36 -11
- pymax/api/self/service.py +18 -6
- pymax/api/session/payloads.py +9 -2
- pymax/api/uploads/models.py +1 -4
- pymax/api/uploads/payloads.py +9 -3
- pymax/api/uploads/service.py +103 -35
- pymax/api/users/service.py +15 -5
- pymax/app.py +15 -5
- pymax/auth/qr.py +11 -6
- pymax/auth/sms.py +13 -4
- pymax/base.py +1 -0
- pymax/client.py +4 -1
- pymax/client_web.py +4 -2
- pymax/config.py +11 -3
- pymax/connection/connection.py +15 -5
- pymax/connection/readers/tcp.py +4 -2
- pymax/dispatch/dispatcher.py +28 -10
- pymax/dispatch/mapping.py +9 -2
- pymax/dispatch/router.py +2 -0
- pymax/files/base.py +6 -1
- pymax/formatting/markdown.py +4 -1
- pymax/infra/auth.py +42 -0
- pymax/infra/base.py +2 -0
- pymax/infra/bots.py +33 -0
- pymax/infra/chat.py +102 -1
- pymax/protocol/tcp/compression.py +3 -1
- pymax/protocol/tcp/framing.py +6 -11
- pymax/protocol/tcp/payload.py +3 -2
- pymax/protocol/tcp/protocol.py +13 -3
- pymax/protocol/ws/protocol.py +9 -3
- pymax/session/protocol.py +6 -2
- pymax/session/store.py +24 -8
- pymax/telemetry/navigation.py +3 -1
- pymax/telemetry/service.py +9 -3
- pymax/transport/tcp.py +10 -4
- pymax/transport/websocket.py +0 -2
- pymax/types/domain/__init__.py +3 -0
- pymax/types/domain/bots.py +14 -0
- pymax/types/domain/error.py +3 -3
- pymax/types/domain/folder.py +1 -1
- pymax/types/domain/login.py +18 -6
- pymax/types/domain/member.py +16 -0
- pymax/types/domain/presence.py +15 -0
- pymax/types/domain/sync.py +21 -5
- pymax/types/domain/user.py +12 -0
- {maxapi_python-2.0.1.dist-info → maxapi_python-2.1.0.dist-info}/WHEEL +0 -0
- {maxapi_python-2.0.1.dist-info → maxapi_python-2.1.0.dist-info}/licenses/LICENSE +0 -0
pymax/api/uploads/service.py
CHANGED
|
@@ -38,10 +38,18 @@ logger = get_logger(__name__)
|
|
|
38
38
|
class UploadService:
|
|
39
39
|
def __init__(self, app: App) -> None:
|
|
40
40
|
self.app = app
|
|
41
|
-
self.video_upload_waiters: dict[
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
self.
|
|
41
|
+
self.video_upload_waiters: dict[
|
|
42
|
+
int, asyncio.Future[VideoUploadSignal]
|
|
43
|
+
] = {}
|
|
44
|
+
self.file_upload_waiters: dict[
|
|
45
|
+
int, asyncio.Future[FileUploadSignal]
|
|
46
|
+
] = {}
|
|
47
|
+
self.app.dispatcher.on_internal(EventType.VIDEO_READY)(
|
|
48
|
+
self.on_video_attach
|
|
49
|
+
)
|
|
50
|
+
self.app.dispatcher.on_internal(EventType.FILE_READY)(
|
|
51
|
+
self.on_file_attach
|
|
52
|
+
)
|
|
45
53
|
|
|
46
54
|
async def upload_photo(self, photo: Photo) -> AttachPhotoPayload:
|
|
47
55
|
logger.info("Uploading photo")
|
|
@@ -62,7 +70,9 @@ class UploadService:
|
|
|
62
70
|
url = payload_item(data, "url", str) # TODO: ENUM!!!!
|
|
63
71
|
except Exception as e:
|
|
64
72
|
logger.exception("Failed to parse photo upload URL from response")
|
|
65
|
-
raise UploadError(
|
|
73
|
+
raise UploadError(
|
|
74
|
+
"Failed to parse photo upload URL from response"
|
|
75
|
+
) from e
|
|
66
76
|
|
|
67
77
|
if not url:
|
|
68
78
|
logger.error("No upload URL received")
|
|
@@ -80,11 +90,15 @@ class UploadService:
|
|
|
80
90
|
except (KeyError, IndexError) as e:
|
|
81
91
|
logger.exception("Photo upload URL does not contain photoIds")
|
|
82
92
|
logger.debug("Invalid photo upload URL=%s", url)
|
|
83
|
-
raise UploadError(
|
|
93
|
+
raise UploadError(
|
|
94
|
+
"Photo upload URL does not contain photoIds"
|
|
95
|
+
) from e
|
|
84
96
|
except Exception as e:
|
|
85
97
|
logger.exception("Failed to parse photo id from upload URL")
|
|
86
98
|
logger.debug("Invalid photo upload URL=%s", url)
|
|
87
|
-
raise UploadError(
|
|
99
|
+
raise UploadError(
|
|
100
|
+
"Failed to parse photo id from upload URL"
|
|
101
|
+
) from e
|
|
88
102
|
|
|
89
103
|
logger.debug("Photo upload id parsed photo_id=%s", photo_id)
|
|
90
104
|
|
|
@@ -122,23 +136,33 @@ class UploadService:
|
|
|
122
136
|
|
|
123
137
|
try:
|
|
124
138
|
async with (
|
|
125
|
-
aiohttp.ClientSession() as session,
|
|
139
|
+
aiohttp.ClientSession(proxy=self.app.config.proxy) as session,
|
|
126
140
|
session.post(
|
|
127
141
|
url=url,
|
|
128
142
|
data=form,
|
|
129
143
|
) as response,
|
|
130
144
|
):
|
|
131
|
-
logger.debug(
|
|
145
|
+
logger.debug(
|
|
146
|
+
"Photo upload HTTP response status=%s", response.status
|
|
147
|
+
)
|
|
132
148
|
|
|
133
149
|
if response.status != HTTPStatus.OK:
|
|
134
|
-
logger.error(
|
|
135
|
-
|
|
150
|
+
logger.error(
|
|
151
|
+
"Photo upload failed with status %s", response.status
|
|
152
|
+
)
|
|
153
|
+
raise UploadError(
|
|
154
|
+
f"Photo upload failed with status {response.status}"
|
|
155
|
+
)
|
|
136
156
|
|
|
137
157
|
try:
|
|
138
158
|
result = await response.json()
|
|
139
159
|
except Exception as e:
|
|
140
|
-
logger.exception(
|
|
141
|
-
|
|
160
|
+
logger.exception(
|
|
161
|
+
"Failed to decode photo upload response JSON"
|
|
162
|
+
)
|
|
163
|
+
raise UploadError(
|
|
164
|
+
"Failed to decode photo upload response JSON"
|
|
165
|
+
) from e
|
|
142
166
|
|
|
143
167
|
except UploadError:
|
|
144
168
|
raise
|
|
@@ -251,8 +275,12 @@ class UploadService:
|
|
|
251
275
|
logger.debug("Video upload waiter registered video_id=%s", video_id)
|
|
252
276
|
|
|
253
277
|
try:
|
|
254
|
-
async with aiohttp.ClientSession(
|
|
255
|
-
|
|
278
|
+
async with aiohttp.ClientSession(
|
|
279
|
+
timeout=timeout, proxy=self.app.config.proxy
|
|
280
|
+
) as session:
|
|
281
|
+
logger.debug(
|
|
282
|
+
"Starting video upload HTTP request video_id=%s", video_id
|
|
283
|
+
)
|
|
256
284
|
|
|
257
285
|
async with session.post(
|
|
258
286
|
url=upload_info.url,
|
|
@@ -297,14 +325,26 @@ class UploadService:
|
|
|
297
325
|
except UploadError:
|
|
298
326
|
raise
|
|
299
327
|
except aiohttp.ClientError as e:
|
|
300
|
-
logger.exception(
|
|
301
|
-
|
|
328
|
+
logger.exception(
|
|
329
|
+
"HTTP error during video upload video_id=%s", video_id
|
|
330
|
+
)
|
|
331
|
+
raise UploadError(
|
|
332
|
+
f"HTTP error during video upload video_id={video_id}"
|
|
333
|
+
) from e
|
|
302
334
|
except asyncio.TimeoutError as e:
|
|
303
|
-
logger.exception(
|
|
304
|
-
|
|
335
|
+
logger.exception(
|
|
336
|
+
"Timed out during video upload video_id=%s", video_id
|
|
337
|
+
)
|
|
338
|
+
raise UploadError(
|
|
339
|
+
f"Timed out during video upload video_id={video_id}"
|
|
340
|
+
) from e
|
|
305
341
|
except Exception as e:
|
|
306
|
-
logger.exception(
|
|
307
|
-
|
|
342
|
+
logger.exception(
|
|
343
|
+
"Unexpected error during video upload video_id=%s", video_id
|
|
344
|
+
)
|
|
345
|
+
raise UploadError(
|
|
346
|
+
f"Unexpected error during video upload video_id={video_id}"
|
|
347
|
+
) from e
|
|
308
348
|
finally:
|
|
309
349
|
self.video_upload_waiters.pop(video_id, None)
|
|
310
350
|
logger.debug("Video upload waiter removed video_id=%s", video_id)
|
|
@@ -371,7 +411,9 @@ class UploadService:
|
|
|
371
411
|
logger.debug("File upload waiter registered file_id=%s", file_id)
|
|
372
412
|
|
|
373
413
|
try:
|
|
374
|
-
async with aiohttp.ClientSession(
|
|
414
|
+
async with aiohttp.ClientSession(
|
|
415
|
+
proxy=self.app.config.proxy,
|
|
416
|
+
) as session:
|
|
375
417
|
async with session.post(
|
|
376
418
|
url=upload_info.url,
|
|
377
419
|
headers=headers,
|
|
@@ -414,44 +456,70 @@ class UploadService:
|
|
|
414
456
|
except UploadError:
|
|
415
457
|
raise
|
|
416
458
|
except aiohttp.ClientError as e:
|
|
417
|
-
logger.exception(
|
|
418
|
-
|
|
459
|
+
logger.exception(
|
|
460
|
+
"HTTP error during file upload file_id=%s", file_id
|
|
461
|
+
)
|
|
462
|
+
raise UploadError(
|
|
463
|
+
f"HTTP error during file upload file_id={file_id}"
|
|
464
|
+
) from e
|
|
419
465
|
except asyncio.TimeoutError as e:
|
|
420
|
-
logger.exception(
|
|
421
|
-
|
|
466
|
+
logger.exception(
|
|
467
|
+
"Timed out during file upload file_id=%s", file_id
|
|
468
|
+
)
|
|
469
|
+
raise UploadError(
|
|
470
|
+
f"Timed out during file upload file_id={file_id}"
|
|
471
|
+
) from e
|
|
422
472
|
except Exception as e:
|
|
423
|
-
logger.exception(
|
|
424
|
-
|
|
473
|
+
logger.exception(
|
|
474
|
+
"Unexpected error during file upload file_id=%s", file_id
|
|
475
|
+
)
|
|
476
|
+
raise UploadError(
|
|
477
|
+
f"Unexpected error during file upload file_id={file_id}"
|
|
478
|
+
) from e
|
|
425
479
|
finally:
|
|
426
480
|
self.file_upload_waiters.pop(file_id, None)
|
|
427
481
|
logger.debug("File upload waiter removed file=%s", file_id)
|
|
428
482
|
|
|
429
|
-
async def on_video_attach(
|
|
483
|
+
async def on_video_attach(
|
|
484
|
+
self, attach: VideoUploadSignal, _: Client
|
|
485
|
+
) -> None:
|
|
430
486
|
logger.debug("Received attach event video_id=%s", attach.video_id)
|
|
431
487
|
|
|
432
488
|
future = self.video_upload_waiters.pop(attach.video_id, None)
|
|
433
489
|
|
|
434
490
|
if not future:
|
|
435
|
-
logger.debug(
|
|
491
|
+
logger.debug(
|
|
492
|
+
"No video upload waiter found video_id=%s", attach.video_id
|
|
493
|
+
)
|
|
436
494
|
return
|
|
437
495
|
|
|
438
496
|
if future.done():
|
|
439
|
-
logger.debug(
|
|
497
|
+
logger.debug(
|
|
498
|
+
"Video upload waiter already done video_id=%s", attach.video_id
|
|
499
|
+
)
|
|
440
500
|
return
|
|
441
501
|
|
|
442
502
|
future.set_result(attach)
|
|
443
|
-
logger.debug(
|
|
503
|
+
logger.debug(
|
|
504
|
+
"Video upload waiter resolved video_id=%s", attach.video_id
|
|
505
|
+
)
|
|
444
506
|
|
|
445
|
-
async def on_file_attach(
|
|
507
|
+
async def on_file_attach(
|
|
508
|
+
self, attach: FileUploadSignal, _: Client
|
|
509
|
+
) -> None:
|
|
446
510
|
logger.debug("Received attach event file_id=%s", attach.file_id)
|
|
447
511
|
future = self.file_upload_waiters.pop(attach.file_id, None)
|
|
448
512
|
|
|
449
513
|
if not future:
|
|
450
|
-
logger.debug(
|
|
514
|
+
logger.debug(
|
|
515
|
+
"No file upload waiter found file_id=%s", attach.file_id
|
|
516
|
+
)
|
|
451
517
|
return
|
|
452
518
|
|
|
453
519
|
if future.done():
|
|
454
|
-
logger.debug(
|
|
520
|
+
logger.debug(
|
|
521
|
+
"File upload waiter already done file_id=%s", attach.file_id
|
|
522
|
+
)
|
|
455
523
|
return
|
|
456
524
|
|
|
457
525
|
future.set_result(attach)
|
pymax/api/users/service.py
CHANGED
|
@@ -44,7 +44,9 @@ class UserService:
|
|
|
44
44
|
for user_id in user_ids
|
|
45
45
|
if (user := self.get_cached_user(user_id)) is not None
|
|
46
46
|
}
|
|
47
|
-
missing_ids = [
|
|
47
|
+
missing_ids = [
|
|
48
|
+
user_id for user_id in user_ids if user_id not in cached
|
|
49
|
+
]
|
|
48
50
|
|
|
49
51
|
if missing_ids:
|
|
50
52
|
for user in await self.fetch_users(missing_ids):
|
|
@@ -62,11 +64,15 @@ class UserService:
|
|
|
62
64
|
async def fetch_users(self, user_ids: list[int]) -> list[User]:
|
|
63
65
|
logger.info("fetching users count=%s", len(user_ids))
|
|
64
66
|
frame = FetchContactsPayload(contact_ids=user_ids)
|
|
65
|
-
response = await self.app.invoke(
|
|
67
|
+
response = await self.app.invoke(
|
|
68
|
+
Opcode.CONTACT_INFO, frame.to_payload()
|
|
69
|
+
)
|
|
66
70
|
|
|
67
71
|
users = [
|
|
68
72
|
self._cache_user(user)
|
|
69
|
-
for user in parse_payload_list(
|
|
73
|
+
for user in parse_payload_list(
|
|
74
|
+
response, UserPayloadKey.CONTACTS, User
|
|
75
|
+
)
|
|
70
76
|
]
|
|
71
77
|
logger.debug("fetched users count=%s", len(users))
|
|
72
78
|
return users
|
|
@@ -91,8 +97,12 @@ class UserService:
|
|
|
91
97
|
response = await self.app.invoke(Opcode.SESSIONS_INFO, {})
|
|
92
98
|
return parse_payload_list(response, UserPayloadKey.SESSIONS, Session)
|
|
93
99
|
|
|
94
|
-
async def _contact_action(
|
|
95
|
-
|
|
100
|
+
async def _contact_action(
|
|
101
|
+
self, payload: ContactActionPayload
|
|
102
|
+
) -> InboundFrame:
|
|
103
|
+
response = await self.app.invoke(
|
|
104
|
+
Opcode.CONTACT_UPDATE, payload.to_payload()
|
|
105
|
+
)
|
|
96
106
|
require_payload_dict(response)
|
|
97
107
|
return response
|
|
98
108
|
|
pymax/app.py
CHANGED
|
@@ -33,7 +33,9 @@ class App(Generic[ClientT]):
|
|
|
33
33
|
self.dispatcher: Dispatcher[ClientT] = Dispatcher(self, root_router)
|
|
34
34
|
self.api = ApiFacade(self)
|
|
35
35
|
self.config = config
|
|
36
|
-
self.store = self.config.store or SessionStore(
|
|
36
|
+
self.store = self.config.store or SessionStore(
|
|
37
|
+
config.work_dir, config.session_name
|
|
38
|
+
)
|
|
37
39
|
self.auth_flow = auth_flow
|
|
38
40
|
|
|
39
41
|
self.me: Profile | None = None
|
|
@@ -74,14 +76,18 @@ class App(Generic[ClientT]):
|
|
|
74
76
|
await self.connection.open()
|
|
75
77
|
|
|
76
78
|
handshake_device_id = (
|
|
77
|
-
session_data.device_id
|
|
79
|
+
session_data.device_id
|
|
80
|
+
if session_data
|
|
81
|
+
else self.config.device.device_id
|
|
78
82
|
)
|
|
79
83
|
logger.debug("running handshake")
|
|
80
84
|
await self.handshake(handshake_device_id)
|
|
81
85
|
except (ConnectionError, EOFError, OSError, TimeoutError) as e:
|
|
82
86
|
logger.exception("failed to connect or handshake")
|
|
83
87
|
await self.connection.close()
|
|
84
|
-
raise ConnectionError(
|
|
88
|
+
raise ConnectionError(
|
|
89
|
+
f"Failed to connect and handshake: {e}"
|
|
90
|
+
) from e
|
|
85
91
|
|
|
86
92
|
self._ping_task = asyncio.create_task(self._ping_loop())
|
|
87
93
|
|
|
@@ -102,7 +108,9 @@ class App(Generic[ClientT]):
|
|
|
102
108
|
|
|
103
109
|
if not auth_result.token:
|
|
104
110
|
logger.error("authentication finished without token")
|
|
105
|
-
raise RuntimeError(
|
|
111
|
+
raise RuntimeError(
|
|
112
|
+
"Authentication failed: no token received"
|
|
113
|
+
)
|
|
106
114
|
|
|
107
115
|
await self.store.save_session(
|
|
108
116
|
session_data := SessionInfo(
|
|
@@ -200,7 +208,9 @@ class App(Generic[ClientT]):
|
|
|
200
208
|
)
|
|
201
209
|
logger.debug("Request data=%s", frame.model_dump())
|
|
202
210
|
response = await self.connection.request(frame, timeout=timeout)
|
|
203
|
-
response_keys =
|
|
211
|
+
response_keys = (
|
|
212
|
+
sorted(response.payload.keys()) if response.payload else []
|
|
213
|
+
)
|
|
204
214
|
logger.debug(
|
|
205
215
|
"response opcode=%s cmd=%s seq=%s payload_keys=%s",
|
|
206
216
|
response.opcode,
|
pymax/auth/qr.py
CHANGED
|
@@ -4,8 +4,7 @@ import asyncio
|
|
|
4
4
|
import time
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
|
-
import
|
|
8
|
-
|
|
7
|
+
from pymax.auth.base import AuthFlow
|
|
9
8
|
from pymax.exceptions import ApiError
|
|
10
9
|
from pymax.logging import get_logger
|
|
11
10
|
|
|
@@ -20,7 +19,7 @@ if TYPE_CHECKING:
|
|
|
20
19
|
logger = get_logger(__name__)
|
|
21
20
|
|
|
22
21
|
|
|
23
|
-
class QrAuthFlow:
|
|
22
|
+
class QrAuthFlow(AuthFlow):
|
|
24
23
|
"""Стандартная QR-авторизация ``WebClient``.
|
|
25
24
|
|
|
26
25
|
Flow получает QR-ссылку, передает ее в ``QrHandler``, ждет подтверждения и
|
|
@@ -119,17 +118,23 @@ class QrAuthFlow:
|
|
|
119
118
|
continue
|
|
120
119
|
|
|
121
120
|
try:
|
|
122
|
-
response = await app.api.auth.check_password(
|
|
121
|
+
response = await app.api.auth.check_password(
|
|
122
|
+
track_id, password
|
|
123
|
+
)
|
|
123
124
|
except ApiError as e:
|
|
124
125
|
logger.error("2fa password check failed: %s", e)
|
|
125
126
|
continue
|
|
126
127
|
|
|
127
128
|
if response.error:
|
|
128
|
-
logger.error(
|
|
129
|
+
logger.error(
|
|
130
|
+
"2fa password check failed error=%s", response.error
|
|
131
|
+
)
|
|
129
132
|
continue
|
|
130
133
|
|
|
131
134
|
if response.login_token:
|
|
132
135
|
logger.info("2fa password authentication completed")
|
|
133
136
|
return response.login_token
|
|
134
137
|
|
|
135
|
-
logger.error(
|
|
138
|
+
logger.error(
|
|
139
|
+
"2fa password response did not contain login token; retrying"
|
|
140
|
+
)
|
pymax/auth/sms.py
CHANGED
|
@@ -2,11 +2,16 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import TYPE_CHECKING
|
|
4
4
|
|
|
5
|
+
from pymax.auth.base import AuthFlow
|
|
5
6
|
from pymax.exceptions import ApiError
|
|
6
7
|
from pymax.logging import get_logger
|
|
7
8
|
|
|
8
9
|
from .models import AuthResult
|
|
9
|
-
from .providers import
|
|
10
|
+
from .providers import (
|
|
11
|
+
ConsolePasswordProvider,
|
|
12
|
+
PasswordProvider,
|
|
13
|
+
SmsCodeProvider,
|
|
14
|
+
)
|
|
10
15
|
|
|
11
16
|
if TYPE_CHECKING:
|
|
12
17
|
from pymax.app import App
|
|
@@ -15,7 +20,7 @@ if TYPE_CHECKING:
|
|
|
15
20
|
logger = get_logger(__name__)
|
|
16
21
|
|
|
17
22
|
|
|
18
|
-
class SmsAuthFlow:
|
|
23
|
+
class SmsAuthFlow(AuthFlow):
|
|
19
24
|
"""Стандартная SMS-авторизация ``Client``.
|
|
20
25
|
|
|
21
26
|
Flow запрашивает SMS-код, отправляет его в Max, при необходимости проходит
|
|
@@ -104,13 +109,17 @@ class SmsAuthFlow:
|
|
|
104
109
|
continue
|
|
105
110
|
|
|
106
111
|
try:
|
|
107
|
-
response = await app.api.auth.check_password(
|
|
112
|
+
response = await app.api.auth.check_password(
|
|
113
|
+
track_id, password
|
|
114
|
+
)
|
|
108
115
|
except ApiError as e:
|
|
109
116
|
logger.error("2fa password check failed: %s", e)
|
|
110
117
|
continue
|
|
111
118
|
|
|
112
119
|
if response.error:
|
|
113
|
-
logger.error(
|
|
120
|
+
logger.error(
|
|
121
|
+
"2fa password check failed error=%s", response.error
|
|
122
|
+
)
|
|
114
123
|
continue
|
|
115
124
|
|
|
116
125
|
if response.login_token:
|
pymax/base.py
CHANGED
|
@@ -82,6 +82,7 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
|
|
|
82
82
|
telemetry=self.extra_config.telemetry,
|
|
83
83
|
sync=self.extra_config.sync,
|
|
84
84
|
store=self.extra_config.store,
|
|
85
|
+
proxy=self.extra_config.proxy,
|
|
85
86
|
device=DeviceConfig(
|
|
86
87
|
mt_instance_id=self.extra_config.mt_instance_id,
|
|
87
88
|
device_id=self.extra_config.device_id or str(uuid4()),
|
pymax/client.py
CHANGED
|
@@ -66,7 +66,10 @@ class Client(BaseClient["Client"]):
|
|
|
66
66
|
|
|
67
67
|
self._config = self._build_config(
|
|
68
68
|
phone=phone,
|
|
69
|
-
user_agent=(
|
|
69
|
+
user_agent=(
|
|
70
|
+
self.extra_config.user_agent
|
|
71
|
+
or self.extra_config.generate_user_agent()
|
|
72
|
+
),
|
|
70
73
|
)
|
|
71
74
|
|
|
72
75
|
if auth_flow is None:
|
pymax/client_web.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from pymax.auth.base import AuthFlow
|
|
3
4
|
from pymax.auth.providers import ConsoleQrHandler, QrHandler
|
|
4
5
|
from pymax.auth.qr import QrAuthFlow
|
|
5
6
|
from pymax.connection import ConnectionManager
|
|
@@ -34,7 +35,7 @@ class WebClient(BaseClient["WebClient"]):
|
|
|
34
35
|
session_name: str = "session.db",
|
|
35
36
|
work_dir: str = ".",
|
|
36
37
|
extra_config: ExtraConfig | None = None,
|
|
37
|
-
auth_flow:
|
|
38
|
+
auth_flow: AuthFlow | None = None,
|
|
38
39
|
qr_provider: QrHandler | None = None,
|
|
39
40
|
) -> None:
|
|
40
41
|
self.extra_config = extra_config or ExtraConfig()
|
|
@@ -53,7 +54,8 @@ class WebClient(BaseClient["WebClient"]):
|
|
|
53
54
|
self._config = self._build_config(
|
|
54
55
|
phone=None,
|
|
55
56
|
user_agent=(
|
|
56
|
-
self.extra_config.user_agent
|
|
57
|
+
self.extra_config.user_agent
|
|
58
|
+
or self.extra_config.generate_web_user_agent()
|
|
57
59
|
),
|
|
58
60
|
)
|
|
59
61
|
|
pymax/config.py
CHANGED
|
@@ -8,7 +8,7 @@ from pymax.api.session.payloads import (
|
|
|
8
8
|
DEFAULT_WEB_HEADER_USER_AGENT,
|
|
9
9
|
MobileUserAgentPayload,
|
|
10
10
|
)
|
|
11
|
-
from pymax.session import
|
|
11
|
+
from pymax.session import StoreProtocol
|
|
12
12
|
from pymax.types.domain.sync import SyncOverrides
|
|
13
13
|
|
|
14
14
|
APP_VERSIONS: tuple[tuple[str, int], ...] = (
|
|
@@ -35,7 +35,12 @@ ANDROID_DEVICES: tuple[tuple[str, str, str, str], ...] = (
|
|
|
35
35
|
("Xiaomi 2201117TG", "Android 13", "395dpi 395dpi 1080x2400", "arm64-v8a"),
|
|
36
36
|
("Xiaomi 2201123G", "Android 14", "526dpi 526dpi 1440x3200", "arm64-v8a"),
|
|
37
37
|
("Xiaomi 2210132G", "Android 14", "446dpi 446dpi 1220x2712", "arm64-v8a"),
|
|
38
|
-
(
|
|
38
|
+
(
|
|
39
|
+
"Xiaomi 23049PCD8G",
|
|
40
|
+
"Android 14",
|
|
41
|
+
"446dpi 446dpi 1220x2712",
|
|
42
|
+
"arm64-v8a",
|
|
43
|
+
),
|
|
39
44
|
("Redmi 2201116TG", "Android 13", "395dpi 395dpi 1080x2400", "arm64-v8a"),
|
|
40
45
|
("Redmi 22101316G", "Android 13", "395dpi 395dpi 1080x2400", "arm64-v8a"),
|
|
41
46
|
("Redmi 23021RAA2Y", "Android 14", "395dpi 395dpi 1080x2400", "arm64-v8a"),
|
|
@@ -87,6 +92,7 @@ class ClientConfig(BaseModel):
|
|
|
87
92
|
session_name: str = "session.db"
|
|
88
93
|
device: DeviceConfig
|
|
89
94
|
token: str | None = None
|
|
95
|
+
proxy: str | None = None
|
|
90
96
|
|
|
91
97
|
host: str = "api.oneme.ru"
|
|
92
98
|
port: int = 443
|
|
@@ -103,7 +109,9 @@ class ClientConfig(BaseModel):
|
|
|
103
109
|
|
|
104
110
|
def ensure_config(self) -> None:
|
|
105
111
|
if not self.phone:
|
|
106
|
-
raise ValueError(
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"Phone must be provided when no saved session exists."
|
|
114
|
+
)
|
|
107
115
|
|
|
108
116
|
|
|
109
117
|
class ExtraConfig(BaseModel):
|
pymax/connection/connection.py
CHANGED
|
@@ -80,6 +80,7 @@ class ConnectionManager:
|
|
|
80
80
|
self._connection_lost = True
|
|
81
81
|
self.requests.cancel_all(exc=exc)
|
|
82
82
|
await self.transport.close()
|
|
83
|
+
self._is_open = False
|
|
83
84
|
|
|
84
85
|
async def send(self, frame: OutboundFrame) -> None:
|
|
85
86
|
if not self._is_open:
|
|
@@ -145,19 +146,28 @@ class ConnectionManager:
|
|
|
145
146
|
model = self.protocol.decode(frame)
|
|
146
147
|
await self._handle_inbound(model)
|
|
147
148
|
|
|
148
|
-
except EOFError
|
|
149
|
+
except EOFError:
|
|
149
150
|
logger.warning("connection closed by server")
|
|
150
|
-
self.requests.cancel_all(
|
|
151
|
+
self.requests.cancel_all(
|
|
152
|
+
exc=ConnectionError("Connection closed by the server")
|
|
153
|
+
)
|
|
151
154
|
self._connection_lost = True
|
|
155
|
+
self._is_open = False
|
|
152
156
|
except TimeoutError as e:
|
|
153
157
|
logger.exception("connection timed out")
|
|
154
|
-
self.requests.cancel_all(
|
|
158
|
+
self.requests.cancel_all(
|
|
159
|
+
exc=ConnectionError("Connection timed out")
|
|
160
|
+
)
|
|
155
161
|
self._connection_lost = True
|
|
162
|
+
self._is_open = False
|
|
156
163
|
raise e
|
|
157
164
|
except Exception as e:
|
|
158
165
|
logger.exception("connection receive loop failed")
|
|
159
|
-
self.requests.cancel_all(
|
|
166
|
+
self.requests.cancel_all(
|
|
167
|
+
exc=ConnectionError(f"Connection error: {e}")
|
|
168
|
+
)
|
|
160
169
|
self._connection_lost = True
|
|
170
|
+
self._is_open = False
|
|
161
171
|
raise e
|
|
162
172
|
|
|
163
173
|
async def _handle_inbound(self, frame: InboundFrame) -> None:
|
|
@@ -197,7 +207,7 @@ class ConnectionManager:
|
|
|
197
207
|
)
|
|
198
208
|
|
|
199
209
|
def next_seq(self) -> int:
|
|
200
|
-
self._seq = (self._seq + 1) %
|
|
210
|
+
self._seq = (self._seq + 1) % 0x10000
|
|
201
211
|
return self._seq
|
|
202
212
|
|
|
203
213
|
@property
|
pymax/connection/readers/tcp.py
CHANGED
|
@@ -8,13 +8,15 @@ logger = get_logger(__name__)
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class TCPReader(BaseReader):
|
|
11
|
-
def __init__(
|
|
11
|
+
def __init__(
|
|
12
|
+
self, transport: TCPTransport, framer: TcpPacketFramer
|
|
13
|
+
) -> None:
|
|
12
14
|
super().__init__()
|
|
13
15
|
self.transport = transport
|
|
14
16
|
self.framer = framer
|
|
15
17
|
|
|
16
18
|
async def read(self) -> bytes:
|
|
17
|
-
header_bytes = await self.transport.recv(
|
|
19
|
+
header_bytes = await self.transport.recv(self.framer.HEADER_SIZE)
|
|
18
20
|
payload_len = self.framer.unpack_header(header_bytes)
|
|
19
21
|
if payload_len is None:
|
|
20
22
|
logger.warning(
|