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.
Files changed (61) hide show
  1. {maxapi_python-2.0.1.dist-info → maxapi_python-2.1.0.dist-info}/METADATA +4 -1
  2. {maxapi_python-2.0.1.dist-info → maxapi_python-2.1.0.dist-info}/RECORD +61 -54
  3. pymax/__init__.py +1 -1
  4. pymax/api/auth/enums.py +13 -2
  5. pymax/api/auth/payloads.py +10 -6
  6. pymax/api/auth/service.py +75 -12
  7. pymax/api/bots/__init__.py +1 -0
  8. pymax/api/bots/payloads.py +7 -0
  9. pymax/api/bots/service.py +35 -0
  10. pymax/api/chats/enums.py +1 -0
  11. pymax/api/chats/payloads.py +14 -0
  12. pymax/api/chats/service.py +112 -12
  13. pymax/api/facade.py +2 -0
  14. pymax/api/messages/payloads.py +5 -1
  15. pymax/api/messages/service.py +36 -11
  16. pymax/api/self/service.py +18 -6
  17. pymax/api/session/payloads.py +9 -2
  18. pymax/api/uploads/models.py +1 -4
  19. pymax/api/uploads/payloads.py +9 -3
  20. pymax/api/uploads/service.py +103 -35
  21. pymax/api/users/service.py +15 -5
  22. pymax/app.py +15 -5
  23. pymax/auth/qr.py +11 -6
  24. pymax/auth/sms.py +13 -4
  25. pymax/base.py +1 -0
  26. pymax/client.py +4 -1
  27. pymax/client_web.py +4 -2
  28. pymax/config.py +11 -3
  29. pymax/connection/connection.py +15 -5
  30. pymax/connection/readers/tcp.py +4 -2
  31. pymax/dispatch/dispatcher.py +28 -10
  32. pymax/dispatch/mapping.py +9 -2
  33. pymax/dispatch/router.py +2 -0
  34. pymax/files/base.py +6 -1
  35. pymax/formatting/markdown.py +4 -1
  36. pymax/infra/auth.py +42 -0
  37. pymax/infra/base.py +2 -0
  38. pymax/infra/bots.py +33 -0
  39. pymax/infra/chat.py +102 -1
  40. pymax/protocol/tcp/compression.py +3 -1
  41. pymax/protocol/tcp/framing.py +6 -11
  42. pymax/protocol/tcp/payload.py +3 -2
  43. pymax/protocol/tcp/protocol.py +13 -3
  44. pymax/protocol/ws/protocol.py +9 -3
  45. pymax/session/protocol.py +6 -2
  46. pymax/session/store.py +24 -8
  47. pymax/telemetry/navigation.py +3 -1
  48. pymax/telemetry/service.py +9 -3
  49. pymax/transport/tcp.py +10 -4
  50. pymax/transport/websocket.py +0 -2
  51. pymax/types/domain/__init__.py +3 -0
  52. pymax/types/domain/bots.py +14 -0
  53. pymax/types/domain/error.py +3 -3
  54. pymax/types/domain/folder.py +1 -1
  55. pymax/types/domain/login.py +18 -6
  56. pymax/types/domain/member.py +16 -0
  57. pymax/types/domain/presence.py +15 -0
  58. pymax/types/domain/sync.py +21 -5
  59. pymax/types/domain/user.py +12 -0
  60. {maxapi_python-2.0.1.dist-info → maxapi_python-2.1.0.dist-info}/WHEEL +0 -0
  61. {maxapi_python-2.0.1.dist-info → maxapi_python-2.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -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[int, asyncio.Future[VideoUploadSignal]] = {}
42
- self.file_upload_waiters: dict[int, asyncio.Future[FileUploadSignal]] = {}
43
- self.app.dispatcher.on_internal(EventType.VIDEO_READY)(self.on_video_attach)
44
- self.app.dispatcher.on_internal(EventType.FILE_READY)(self.on_file_attach)
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("Failed to parse photo upload URL from response") from e
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("Photo upload URL does not contain photoIds") from e
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("Failed to parse photo id from upload URL") from e
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("Photo upload HTTP response status=%s", response.status)
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("Photo upload failed with status %s", response.status)
135
- raise UploadError(f"Photo upload failed with status {response.status}")
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("Failed to decode photo upload response JSON")
141
- raise UploadError("Failed to decode photo upload response JSON") from e
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(timeout=timeout) as session:
255
- logger.debug("Starting video upload HTTP request video_id=%s", video_id)
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("HTTP error during video upload video_id=%s", video_id)
301
- raise UploadError(f"HTTP error during video upload video_id={video_id}") from e
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("Timed out during video upload video_id=%s", video_id)
304
- raise UploadError(f"Timed out during video upload video_id={video_id}") from e
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("Unexpected error during video upload video_id=%s", video_id)
307
- raise UploadError(f"Unexpected error during video upload video_id={video_id}") from e
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() as session:
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("HTTP error during file upload file_id=%s", file_id)
418
- raise UploadError(f"HTTP error during file upload file_id={file_id}") from e
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("Timed out during file upload file_id=%s", file_id)
421
- raise UploadError(f"Timed out during file upload file_id={file_id}") from e
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("Unexpected error during file upload file_id=%s", file_id)
424
- raise UploadError(f"Unexpected error during file upload file_id={file_id}") from e
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(self, attach: VideoUploadSignal, _: Client) -> None:
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("No video upload waiter found video_id=%s", attach.video_id)
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("Video upload waiter already done video_id=%s", attach.video_id)
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("Video upload waiter resolved video_id=%s", attach.video_id)
503
+ logger.debug(
504
+ "Video upload waiter resolved video_id=%s", attach.video_id
505
+ )
444
506
 
445
- async def on_file_attach(self, attach: FileUploadSignal, _: Client) -> None:
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("No file upload waiter found file_id=%s", attach.file_id)
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("File upload waiter already done file_id=%s", attach.file_id)
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)
@@ -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 = [user_id for user_id in user_ids if user_id not in cached]
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(Opcode.CONTACT_INFO, frame.to_payload())
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(response, UserPayloadKey.CONTACTS, User)
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(self, payload: ContactActionPayload) -> InboundFrame:
95
- response = await self.app.invoke(Opcode.CONTACT_UPDATE, payload.to_payload())
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(config.work_dir, config.session_name)
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 if session_data else self.config.device.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(f"Failed to connect and handshake: {e}") from e
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("Authentication failed: no token received")
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 = sorted(response.payload.keys()) if response.payload else []
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 qrcode
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(track_id, 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("2fa password check failed error=%s", response.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("2fa password response did not contain login token; retrying")
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 ConsolePasswordProvider, PasswordProvider, SmsCodeProvider
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(track_id, 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("2fa password check failed error=%s", response.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=(self.extra_config.user_agent or self.extra_config.generate_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: QrAuthFlow | None = None,
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 or self.extra_config.generate_web_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 SessionStore, StoreProtocol
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
- ("Xiaomi 23049PCD8G", "Android 14", "446dpi 446dpi 1220x2712", "arm64-v8a"),
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("Phone must be provided when no saved session exists.")
112
+ raise ValueError(
113
+ "Phone must be provided when no saved session exists."
114
+ )
107
115
 
108
116
 
109
117
  class ExtraConfig(BaseModel):
@@ -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 as e:
149
+ except EOFError:
149
150
  logger.warning("connection closed by server")
150
- self.requests.cancel_all(exc=ConnectionError("Connection closed by the server"))
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(exc=ConnectionError("Connection timed out"))
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(exc=ConnectionError(f"Connection error: {e}"))
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) % 0xFFFFFFFF
210
+ self._seq = (self._seq + 1) % 0x10000
201
211
  return self._seq
202
212
 
203
213
  @property
@@ -8,13 +8,15 @@ logger = get_logger(__name__)
8
8
 
9
9
 
10
10
  class TCPReader(BaseReader):
11
- def __init__(self, transport: TCPTransport, framer: TcpPacketFramer) -> None:
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(10)
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(