maxapi-python 2.3.1__py3-none-any.whl → 2.4.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 (59) hide show
  1. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.0.dist-info}/METADATA +3 -1
  2. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.0.dist-info}/RECORD +59 -51
  3. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.0.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +7 -2
  5. pymax/_data/apk_fingerprints.json +682 -0
  6. pymax/api/auth/enums.py +1 -0
  7. pymax/api/auth/payloads.py +28 -2
  8. pymax/api/auth/service.py +88 -3
  9. pymax/api/chats/__init__.py +1 -0
  10. pymax/api/chats/enums.py +15 -0
  11. pymax/api/chats/payloads.py +16 -1
  12. pymax/api/chats/service.py +36 -1
  13. pymax/api/messages/payloads.py +12 -4
  14. pymax/api/messages/service.py +57 -14
  15. pymax/api/self/__init__.py +2 -1
  16. pymax/api/self/enums.py +9 -0
  17. pymax/api/self/payloads.py +40 -1
  18. pymax/api/self/service.py +37 -0
  19. pymax/api/session/service.py +21 -8
  20. pymax/api/uploads/payloads.py +6 -0
  21. pymax/api/uploads/service.py +198 -21
  22. pymax/app.py +69 -15
  23. pymax/base.py +8 -0
  24. pymax/client_web.py +2 -2
  25. pymax/config.py +44 -7
  26. pymax/connection/connection.py +29 -18
  27. pymax/dispatch/enums.py +1 -0
  28. pymax/dispatch/mapping.py +3 -1
  29. pymax/dispatch/resolvers.py +7 -1
  30. pymax/files/__init__.py +4 -1
  31. pymax/files/video.py +58 -0
  32. pymax/files/voice.py +39 -0
  33. pymax/fingerprint/__init__.py +1 -0
  34. pymax/fingerprint/fingerprint.py +47 -0
  35. pymax/fingerprint/models.py +11 -0
  36. pymax/infra/auth.py +4 -0
  37. pymax/infra/bots.py +3 -3
  38. pymax/infra/chat.py +38 -0
  39. pymax/infra/message.py +40 -11
  40. pymax/infra/self.py +25 -1
  41. pymax/protocol/enums.py +13 -0
  42. pymax/protocol/tcp/payload.py +21 -1
  43. pymax/transport/tcp.py +6 -1
  44. pymax/transport/websocket.py +6 -3
  45. pymax/types/domain/__init__.py +2 -1
  46. pymax/types/domain/attachments/__init__.py +2 -1
  47. pymax/types/domain/attachments/enums.py +17 -1
  48. pymax/types/domain/attachments/poll.py +111 -0
  49. pymax/types/domain/attachments/sticker.py +1 -1
  50. pymax/types/domain/attachments/video.py +29 -8
  51. pymax/types/domain/chat.py +9 -9
  52. pymax/types/domain/handshake.py +16 -0
  53. pymax/types/domain/login.py +30 -1
  54. pymax/types/domain/message.py +21 -20
  55. pymax/types/domain/user.py +1 -3
  56. pymax/types/events/__init__.py +1 -0
  57. pymax/types/events/reaction.py +2 -2
  58. pymax/types/events/voice.py +5 -0
  59. {maxapi_python-2.3.1.dist-info → maxapi_python-2.4.0.dist-info}/licenses/LICENSE +0 -0
pymax/api/self/service.py CHANGED
@@ -18,9 +18,12 @@ from pymax.types.domain import FolderList, FolderUpdate, Profile
18
18
  from .enums import SelfPayloadKey
19
19
  from .payloads import (
20
20
  ChangeProfilePayload,
21
+ ChangeProfileSettings,
22
+ ChangeProfileSettingsPayload,
21
23
  CreateFolderPayload,
22
24
  DeleteFolderPayload,
23
25
  GetFolderPayload,
26
+ PrivacySettingsUpdate,
24
27
  UpdateFolderPayload,
25
28
  UploadPayload,
26
29
  )
@@ -150,3 +153,37 @@ class SelfService:
150
153
  logger.info("logging out")
151
154
  await self.app.invoke(Opcode.LOGOUT, {})
152
155
  return True
156
+
157
+ def set_presence(self, online: bool) -> None:
158
+ logger.info("setting presence to %s", "online" if online else "offline")
159
+ self.app.config.interactive = online
160
+
161
+ async def change_profile_settings(self, settings: PrivacySettingsUpdate) -> bool:
162
+ logger.info("changing profile settings")
163
+
164
+ frame = ChangeProfileSettingsPayload(settings=ChangeProfileSettings(user=settings))
165
+
166
+ response = await self.app.invoke(
167
+ Opcode.CONFIG,
168
+ frame.to_payload(),
169
+ )
170
+ session = self.app.session
171
+
172
+ if not session:
173
+ logger.warning("no session found, skipping sync update")
174
+ return True
175
+
176
+ sync = session.sync.model_copy(
177
+ update={"config_hash": require_payload_item(response, SelfPayloadKey.HASH)}
178
+ )
179
+
180
+ updated = session.model_copy(
181
+ update={
182
+ "mt_instance_id": self.app.config.device.mt_instance_id,
183
+ "sync": sync,
184
+ },
185
+ )
186
+ self.app.session = updated
187
+ await self.app.store.save_session(updated)
188
+
189
+ return True
@@ -2,8 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
+ from pymax.api.response import require_payload_model
5
6
  from pymax.logging import get_logger
6
7
  from pymax.protocol import Opcode
8
+ from pymax.types.domain import HandshakeResponse
7
9
 
8
10
  from .enums import DeviceType
9
11
  from .payloads import (
@@ -28,19 +30,18 @@ class SessionService:
28
30
  mt_instance_id: str,
29
31
  user_agent: MobileUserAgentPayload,
30
32
  device_id: str,
31
- ) -> None:
33
+ ) -> HandshakeResponse:
32
34
  if user_agent.device_type == DeviceType.WEB:
33
- await self.web_handshake(user_agent, device_id)
34
- return
35
+ return await self.web_handshake(user_agent, device_id)
35
36
 
36
- await self.mobile_handshake(mt_instance_id, user_agent, device_id)
37
+ return await self.mobile_handshake(mt_instance_id, user_agent, device_id)
37
38
 
38
39
  async def mobile_handshake(
39
40
  self,
40
41
  mt_instance_id: str,
41
42
  user_agent: MobileUserAgentPayload,
42
43
  device_id: str,
43
- ) -> None:
44
+ ) -> HandshakeResponse:
44
45
  logger.debug(
45
46
  "mobile handshake mt_instance_id_set=%s device_id=%s app_version=%s",
46
47
  bool(mt_instance_id),
@@ -52,10 +53,17 @@ class SessionService:
52
53
  user_agent=user_agent,
53
54
  device_id=device_id,
54
55
  )
55
- await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload())
56
+ response = await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload())
56
57
  logger.info("mobile handshake completed")
57
58
 
58
- async def web_handshake(self, user_agent: MobileUserAgentPayload, device_id: str) -> None:
59
+ return require_payload_model(
60
+ response,
61
+ HandshakeResponse,
62
+ )
63
+
64
+ async def web_handshake(
65
+ self, user_agent: MobileUserAgentPayload, device_id: str
66
+ ) -> HandshakeResponse:
59
67
  logger.debug(
60
68
  "web handshake device_id=%s app_version=%s browser=%s",
61
69
  device_id,
@@ -66,5 +74,10 @@ class SessionService:
66
74
  user_agent=user_agent,
67
75
  device_id=device_id,
68
76
  )
69
- await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload())
77
+ response = await self.app.invoke(Opcode.SESSION_INIT, frame.to_payload())
70
78
  logger.info("web handshake completed")
79
+
80
+ return require_payload_model(
81
+ response,
82
+ HandshakeResponse,
83
+ )
@@ -13,6 +13,10 @@ class VideoAttachPayload(CamelModel):
13
13
  type: AttachmentType = Field(default=AttachmentType.VIDEO, serialization_alias="_type")
14
14
  video_id: int
15
15
  token: str
16
+ video_type: int = 0
17
+ thumbhash: bytes | None = None
18
+ duration: int | None = None
19
+ wave: bytes | None = None
16
20
 
17
21
 
18
22
  class AttachFilePayload(CamelModel):
@@ -22,4 +26,6 @@ class AttachFilePayload(CamelModel):
22
26
 
23
27
  class UploadPayload(CamelModel):
24
28
  count: int = 1
29
+ type: int = 0
30
+ uploader_type: int = 0
25
31
  profile: bool = False
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import base64
4
5
  from http import HTTPStatus
5
6
  from typing import TYPE_CHECKING
6
7
  from urllib.parse import parse_qs, quote, urlparse
@@ -11,9 +12,10 @@ from pydantic import ValidationError
11
12
  from pymax.api.response import payload_item
12
13
  from pymax.dispatch.enums import EventType
13
14
  from pymax.exceptions import UploadError
14
- from pymax.files import File, Photo, Video
15
+ from pymax.files import File, Photo, Video, VideoNote, Voice
15
16
  from pymax.logging import get_logger
16
17
  from pymax.protocol import Opcode
18
+ from pymax.types import AttachmentType, AudioUploadSignal
17
19
 
18
20
  from .models import (
19
21
  FileUploadResponse,
@@ -40,14 +42,16 @@ class UploadService:
40
42
  self.app = app
41
43
  self.video_upload_waiters: dict[int, asyncio.Future[VideoUploadSignal]] = {}
42
44
  self.file_upload_waiters: dict[int, asyncio.Future[FileUploadSignal]] = {}
45
+ self.voice_upload_waiters: dict[int, asyncio.Future[AudioUploadSignal]] = {}
43
46
  self.app.dispatcher.on_internal(EventType.VIDEO_READY)(self.on_video_attach)
44
47
  self.app.dispatcher.on_internal(EventType.FILE_READY)(self.on_file_attach)
48
+ self.app.dispatcher.on_internal(EventType.VOICE_READY)(self.on_voice_attach)
45
49
 
46
50
  async def upload_photo(self, photo: Photo, profile: bool = False) -> AttachPhotoPayload:
47
51
  logger.info("Uploading photo")
48
52
  logger.debug("Preparing photo upload payload")
49
53
 
50
- payload = UploadPayload(profile=profile).model_dump()
54
+ payload = UploadPayload(profile=profile).to_payload()
51
55
 
52
56
  try:
53
57
  data = await self.app.invoke(
@@ -178,11 +182,146 @@ class UploadService:
178
182
  logger.debug("Photo upload complete photo_id=%s", photo_id)
179
183
  return AttachPhotoPayload(photo_token=token)
180
184
 
181
- async def upload_video(self, video: Video) -> VideoAttachPayload:
185
+ async def upload_voice(self, voice: Voice) -> VideoAttachPayload:
186
+ logger.info("Uploading voice")
187
+
188
+ payload = UploadPayload(
189
+ type=2,
190
+ uploader_type=1,
191
+ ).to_payload()
192
+
193
+ try:
194
+ data = await self.app.invoke(
195
+ Opcode.VIDEO_UPLOAD,
196
+ payload=payload,
197
+ )
198
+ except Exception as e:
199
+ logger.exception("Failed to request voice upload URL")
200
+ raise UploadError("Failed to request voice upload URL") from e
201
+
202
+ try:
203
+ response = VideoUploadResponse.model_validate(data.payload)
204
+ except ValidationError as e:
205
+ logger.exception("Invalid voice upload response model")
206
+ logger.debug("Invalid voice upload payload=%r", data.payload)
207
+ raise UploadError("Invalid voice upload response model") from e
208
+ except Exception as e:
209
+ logger.exception("Failed to parse voice upload response")
210
+ logger.debug("Invalid voice upload payload=%r", data.payload)
211
+ raise UploadError("Failed to parse voice upload response") from e
212
+
213
+ try:
214
+ upload_info = response.info[0]
215
+ except IndexError as e:
216
+ logger.error("voice upload response info is empty")
217
+ logger.debug("voice upload response=%r", response)
218
+ raise UploadError("voice upload response info is empty") from e
219
+ except Exception as e:
220
+ logger.exception("Failed to get voice upload info")
221
+ logger.debug("voice upload response=%r", response)
222
+ raise UploadError("Failed to get voice upload info") from e
223
+
224
+ try:
225
+ file_size = await voice.size()
226
+ except Exception as e:
227
+ logger.exception("Failed to get voice size")
228
+ raise UploadError("Failed to get voice size") from e
229
+
230
+ headers = {
231
+ "Content-Disposition": f"attachment; filename={quote(voice.name)}",
232
+ "Content-Range": f"0-{file_size - 1}/{file_size}",
233
+ "Content-Length": str(file_size),
234
+ "Connection": "keep-alive",
235
+ "Content-Type": "application/octet-stream",
236
+ }
237
+
238
+ logger.debug(
239
+ "Voice upload headers prepared content_range=%s",
240
+ headers["Content-Range"],
241
+ )
242
+
243
+ loop = asyncio.get_running_loop()
244
+ future: asyncio.Future[AudioUploadSignal] = loop.create_future()
245
+
246
+ timeout = aiohttp.ClientTimeout(total=900, sock_read=60)
247
+
248
+ video_id = upload_info.video_id
249
+ token = upload_info.token
250
+
251
+ self.voice_upload_waiters[video_id] = future
252
+ logger.debug("Voice upload waiter registered voice_id=%s", video_id)
253
+
254
+ try:
255
+ async with aiohttp.ClientSession(
256
+ timeout=timeout, proxy=self.app.config.proxy
257
+ ) as session:
258
+ logger.debug("Starting voice upload HTTP request voice_id=%s", video_id)
259
+
260
+ async with session.post(
261
+ url=upload_info.url,
262
+ headers=headers,
263
+ data=voice.iter_chunks(1024 * 1024),
264
+ ) as response:
265
+ logger.debug(
266
+ "Voice upload HTTP response status=%s voice_id=%s",
267
+ response.status,
268
+ video_id,
269
+ )
270
+
271
+ if response.status != HTTPStatus.OK:
272
+ logger.error(
273
+ "Voice upload failed with status %s video_id=%s",
274
+ response.status,
275
+ video_id,
276
+ )
277
+ raise UploadError(
278
+ "Voice upload failed with status "
279
+ f"{response.status} voice_id={video_id}"
280
+ )
281
+
282
+ try:
283
+ logger.debug(
284
+ "Waiting for voice processing notification voice_id=%s",
285
+ video_id,
286
+ )
287
+ await asyncio.wait_for(future, 60)
288
+ except asyncio.TimeoutError:
289
+ logger.warning(
290
+ "Timed out waiting for voice processing notification voice_id=%s",
291
+ video_id,
292
+ )
293
+ raise UploadError(
294
+ f"Timed out waiting for voice processing voice_id={video_id}"
295
+ )
296
+
297
+ logger.debug("Voice upload complete voice_id=%s", video_id)
298
+ return VideoAttachPayload(
299
+ type=AttachmentType.AUDIO, video_id=video_id, token=token
300
+ )
301
+
302
+ except UploadError:
303
+ raise
304
+ except aiohttp.ClientError as e:
305
+ logger.exception("HTTP error during voice upload voice_id=%s", video_id)
306
+ raise UploadError(f"HTTP error during voice upload voice_id={video_id}") from e
307
+ except asyncio.TimeoutError as e:
308
+ logger.exception("Timed out during voice upload voice_id=%s", video_id)
309
+ raise UploadError(f"Timed out during voice upload voice_id={video_id}") from e
310
+ except Exception as e:
311
+ logger.exception("Unexpected error during voice upload voice_id=%s", video_id)
312
+ raise UploadError(f"Unexpected error during voice upload voice_id={video_id}") from e
313
+ finally:
314
+ self.voice_upload_waiters.pop(video_id, None)
315
+ logger.debug("Voice upload waiter removed voice_id=%s", video_id)
316
+
317
+ async def upload_video(self, uploadable_video: Video | VideoNote) -> VideoAttachPayload:
182
318
  logger.info("Uploading video")
183
319
  logger.debug("Preparing video upload payload")
184
320
 
185
- payload = UploadPayload().model_dump()
321
+ if isinstance(uploadable_video, VideoNote):
322
+ payload = UploadPayload(type=1, uploader_type=1).to_payload()
323
+ else:
324
+ payload = UploadPayload().to_payload()
186
325
 
187
326
  try:
188
327
  data = await self.app.invoke(
@@ -216,7 +355,7 @@ class UploadService:
216
355
  raise UploadError("Failed to get video upload info") from e
217
356
 
218
357
  try:
219
- file_size = await video.size()
358
+ file_size = await uploadable_video.size()
220
359
  except Exception as e:
221
360
  logger.exception("Failed to get video size")
222
361
  raise UploadError("Failed to get video size") from e
@@ -230,8 +369,8 @@ class UploadService:
230
369
  timeout = aiohttp.ClientTimeout(total=900, sock_read=60)
231
370
 
232
371
  headers = {
233
- "Content-Disposition": f"attachment; filename={quote(video.name)}",
234
- "Content-Range": f"0-{file_size - 1}/{file_size}",
372
+ "Content-Disposition": f"attachment; filename={quote(uploadable_video.name)}",
373
+ "Content-Range": f"bytes 0-{file_size - 1}/{file_size}",
235
374
  "Content-Length": str(file_size),
236
375
  "Connection": "keep-alive",
237
376
  }
@@ -240,15 +379,16 @@ class UploadService:
240
379
  "Video upload headers prepared content_range=%s",
241
380
  headers["Content-Range"],
242
381
  )
243
-
244
- loop = asyncio.get_running_loop()
245
- future: asyncio.Future[VideoUploadSignal] = loop.create_future()
246
-
247
- video_id = upload_info.video_id
248
382
  token = upload_info.token
383
+ video_id = upload_info.video_id
384
+
385
+ future = None
386
+ if not isinstance(uploadable_video, VideoNote):
387
+ loop = asyncio.get_running_loop()
388
+ future = loop.create_future()
389
+ self.video_upload_waiters[video_id] = future
249
390
 
250
- self.video_upload_waiters[video_id] = future
251
- logger.debug("Video upload waiter registered video_id=%s", video_id)
391
+ logger.debug("Video upload waiter registered video_id=%s", video_id)
252
392
 
253
393
  try:
254
394
  async with aiohttp.ClientSession(
@@ -259,7 +399,7 @@ class UploadService:
259
399
  async with session.post(
260
400
  url=upload_info.url,
261
401
  headers=headers,
262
- data=video.iter_chunks(1024 * 1024),
402
+ data=uploadable_video.iter_chunks(1024 * 1024),
263
403
  ) as response:
264
404
  logger.debug(
265
405
  "Video upload HTTP response status=%s video_id=%s",
@@ -279,11 +419,33 @@ class UploadService:
279
419
  )
280
420
 
281
421
  try:
282
- logger.debug(
283
- "Waiting for video processing notification video_id=%s",
284
- video_id,
285
- )
286
- await asyncio.wait_for(future, 60)
422
+ if isinstance(uploadable_video, VideoNote):
423
+ data = await response.json(content_type=None)
424
+
425
+ thumbhash = data.get("thumbhash")
426
+ if thumbhash:
427
+ thumbhash += "=" * (-len(thumbhash) % 4)
428
+ thumbhash = base64.b64decode(thumbhash)
429
+
430
+ return VideoAttachPayload(
431
+ video_id=video_id,
432
+ token=token,
433
+ video_type=1,
434
+ thumbhash=thumbhash,
435
+ duration=await uploadable_video.get_duration(),
436
+ )
437
+ else:
438
+ if future is None:
439
+ raise ValueError(
440
+ "Unexpected internal state: "
441
+ + "future is missing in UploadService.upload_video."
442
+ + f" video type = {type(uploadable_video)}"
443
+ )
444
+ logger.debug(
445
+ "Waiting for video processing notification video_id=%s",
446
+ video_id,
447
+ )
448
+ await asyncio.wait_for(future, 60)
287
449
  except asyncio.TimeoutError:
288
450
  logger.warning(
289
451
  "Timed out waiting for video processing notification video_id=%s",
@@ -294,6 +456,7 @@ class UploadService:
294
456
  )
295
457
 
296
458
  logger.debug("Video upload complete video_id=%s", video_id)
459
+
297
460
  return VideoAttachPayload(video_id=video_id, token=token)
298
461
 
299
462
  except UploadError:
@@ -314,7 +477,7 @@ class UploadService:
314
477
  async def upload_file(self, file: File) -> AttachFilePayload:
315
478
  logger.info("Uploading file")
316
479
 
317
- payload = UploadPayload().model_dump()
480
+ payload = UploadPayload().to_payload()
318
481
 
319
482
  try:
320
483
  data = await self.app.invoke(
@@ -430,6 +593,20 @@ class UploadService:
430
593
  self.file_upload_waiters.pop(file_id, None)
431
594
  logger.debug("File upload waiter removed file=%s", file_id)
432
595
 
596
+ async def on_voice_attach(self, attach: AudioUploadSignal, _: Client) -> None:
597
+ future = self.voice_upload_waiters.pop(attach.audio_id, None)
598
+
599
+ if not future:
600
+ logger.debug("No voice upload waiter found voice_id=%s", attach.audio_id)
601
+ return
602
+
603
+ if future.done():
604
+ logger.debug("Voice upload waiter already done voice_id=%s", attach.audio_id)
605
+ return
606
+
607
+ future.set_result(attach)
608
+ logger.debug("Voice upload waiter resolved voice_id=%s", attach.audio_id)
609
+
433
610
  async def on_video_attach(self, attach: VideoUploadSignal, _: Client) -> None:
434
611
  logger.debug("Received attach event video_id=%s", attach.video_id)
435
612
 
pymax/app.py CHANGED
@@ -8,6 +8,7 @@ from pymax.connection import ConnectionManager
8
8
  from pymax.dispatch import Dispatcher
9
9
  from pymax.dispatch.router import EventType, Router
10
10
  from pymax.exceptions import ApiError
11
+ from pymax.fingerprint import FingerprintGenerator
11
12
  from pymax.logging import get_logger
12
13
  from pymax.protocol import Command, InboundFrame, OutboundFrame
13
14
  from pymax.protocol.enums import Opcode
@@ -15,7 +16,8 @@ from pymax.session import SessionStore
15
16
  from pymax.session.models import SessionInfo
16
17
  from pymax.telemetry import TelemetryService
17
18
  from pymax.types import MaxApiError, Message
18
- from pymax.types.domain import Chat, Profile, User
19
+ from pymax.types.domain import Chat, HandshakeResponse, Login2Response, Profile, User
20
+ from pymax.types.domain.login import LoginResponse
19
21
 
20
22
  if TYPE_CHECKING:
21
23
  from pymax.base import BaseClient
@@ -38,6 +40,7 @@ class App(Generic[ClientT]):
38
40
  self.config = config
39
41
  self.store = self.config.store or SessionStore(config.work_dir, config.session_name)
40
42
  self.auth_flow = auth_flow
43
+ self.fingerprint_generator = FingerprintGenerator()
41
44
 
42
45
  self.me: Profile | None = None
43
46
  self.chats: list[Chat] | None = None
@@ -46,6 +49,7 @@ class App(Generic[ClientT]):
46
49
  self.messages: dict[int, list[Message]] = {}
47
50
 
48
51
  self.session: SessionInfo | None = None
52
+ self.handshake_response: HandshakeResponse | None = None
49
53
 
50
54
  self.started = False
51
55
  self._ping_task: asyncio.Task[None] | None = None
@@ -81,12 +85,14 @@ class App(Generic[ClientT]):
81
85
  session_data.device_id if session_data else self.config.device.device_id
82
86
  )
83
87
  logger.debug("running handshake")
84
- await self.handshake(handshake_device_id)
88
+ handshake_response = await self.handshake(handshake_device_id)
85
89
  except (ConnectionError, EOFError, OSError, TimeoutError) as e:
86
90
  logger.exception("failed to connect or handshake")
87
91
  await self.connection.close()
88
92
  raise ConnectionError(f"Failed to connect and handshake: {e}") from e
89
93
 
94
+ self.handshake_response = handshake_response
95
+
90
96
  self._ping_task = asyncio.create_task(self._ping_loop())
91
97
 
92
98
  if not session_data:
@@ -129,10 +135,26 @@ class App(Generic[ClientT]):
129
135
  logger.debug("logging in")
130
136
 
131
137
  try:
132
- response = await self.api.auth.login(
133
- self.config.device.user_agent,
134
- )
138
+ login_response, login2_response = await self.login()
139
+
140
+ if (
141
+ login2_response
142
+ and login_response.login2_flags
143
+ and login_response.login2_flags.profile_enabled
144
+ and login2_response.profile
145
+ ):
146
+ self.me = login2_response.profile
147
+ elif login_response.profile:
148
+ self.me = login_response.profile
149
+ elif login2_response and login2_response.profile:
150
+ self.me = login2_response.profile
151
+ else:
152
+ logger.error("Unexpected internal state: login response does not contain profile")
153
+ raise RuntimeError("Login response does not contain profile")
135
154
  except Exception as e:
155
+ if self._is_invalid_login_token_error(e):
156
+ raise
157
+
136
158
  handled = False
137
159
  if self.dispatcher.client is not None:
138
160
  handled = await self.dispatcher.emit_error(
@@ -148,15 +170,23 @@ class App(Generic[ClientT]):
148
170
  await self.close()
149
171
  return
150
172
 
151
- if response.token is not None and response.token != self.session.token:
152
- await self.store.update_token(self.session.token, response.token)
153
- self.session.token = response.token
173
+ if login_response.token is not None and login_response.token != self.session.token:
174
+ await self.store.update_token(self.session.token, login_response.token)
175
+ self.session.token = login_response.token
154
176
 
155
- self.me = response.profile
156
- self.chats = response.chats
177
+ self.chats = login_response.chats
157
178
  self.users[self.me.contact.id] = self.me.contact
158
- self.contacts = response.contacts
159
- self.messages = response.messages
179
+
180
+ if (
181
+ login2_response
182
+ and login_response.login2_flags
183
+ and login_response.login2_flags.contact_enabled
184
+ ):
185
+ self.contacts = login2_response.contacts
186
+ else:
187
+ self.contacts = login_response.contacts
188
+
189
+ self.messages = login_response.messages
160
190
 
161
191
  self.started = True
162
192
  logger.info(
@@ -168,13 +198,37 @@ class App(Generic[ClientT]):
168
198
  if self._telemetry:
169
199
  self._telemetry.start()
170
200
 
171
- async def handshake(self, device_id: str) -> None:
172
- await self.api.session.handshake(
201
+ @staticmethod
202
+ def _is_invalid_login_token_error(exc: Exception) -> bool:
203
+ return (
204
+ isinstance(exc, ApiError)
205
+ and exc.opcode == Opcode.LOGIN
206
+ and any(
207
+ err in (exc.error, exc.message) for err in ("FAIL_LOGIN_TOKEN", "FAIL_LOGOUT_ALL")
208
+ )
209
+ )
210
+
211
+ async def login(self) -> tuple[LoginResponse, Login2Response | None]:
212
+ login_response = await self.api.auth.login(
213
+ self.config.device.user_agent,
214
+ )
215
+
216
+ if login_response.login2_flags and login_response.login2_flags.enabled:
217
+ logger.debug("login2 required; proceeding with login2")
218
+ login2_response = await self.api.auth.mobile_login2(login_response.login2_flags)
219
+
220
+ return login_response, login2_response
221
+
222
+ return login_response, None
223
+
224
+ async def handshake(self, device_id: str) -> HandshakeResponse:
225
+ response = await self.api.session.handshake(
173
226
  self.config.device.mt_instance_id,
174
227
  self.config.device.user_agent,
175
228
  device_id,
176
229
  )
177
230
  logger.debug("handshake completed device_id=%s", device_id)
231
+ return response
178
232
 
179
233
  async def close(self) -> None:
180
234
  if self._telemetry:
@@ -246,7 +300,7 @@ class App(Generic[ClientT]):
246
300
  while True:
247
301
  await self.invoke(
248
302
  opcode=Opcode.PING,
249
- payload={"interactive": True},
303
+ payload={"interactive": self.config.interactive},
250
304
  timeout=self.config.request_timeout,
251
305
  )
252
306
  await asyncio.sleep(30)
pymax/base.py CHANGED
@@ -7,6 +7,7 @@ from uuid import uuid4
7
7
 
8
8
  from pymax.dispatch import ErrorScope, Router
9
9
  from pymax.dispatch.router import DisconnectDecorator, ErrorDecorator
10
+ from pymax.exceptions import ApiError
10
11
  from pymax.infra import BaseMixin
11
12
  from pymax.logging import get_logger
12
13
 
@@ -138,6 +139,13 @@ class BaseClient(BaseMixin, ABC, Generic[ClientT]):
138
139
  except asyncio.CancelledError:
139
140
  await self.close()
140
141
  raise
142
+ except ApiError as e:
143
+ if not self._app._is_invalid_login_token_error(e):
144
+ await self.close()
145
+ raise
146
+
147
+ logger.warning("login token was revoked; starting authentication again")
148
+ await self.relogin(start=False)
141
149
  except ( # noqa: PERF203
142
150
  ConnectionError,
143
151
  EOFError,
pymax/client_web.py CHANGED
@@ -6,7 +6,7 @@ from pymax.auth.qr import QrAuthFlow
6
6
  from pymax.connection import ConnectionManager
7
7
  from pymax.connection.readers import WSReader
8
8
  from pymax.logging import configure_logging, get_logger
9
- from pymax.protocol.ws import WsProtocol
9
+ from pymax.protocol.tcp import TcpProtocol
10
10
  from pymax.transport.websocket import WebSocketTransport
11
11
 
12
12
  from .base import BaseClient
@@ -80,5 +80,5 @@ class WebClient(BaseClient["WebClient"]):
80
80
  return ConnectionManager(
81
81
  reader=reader,
82
82
  transport=transport,
83
- protocol=WsProtocol(),
83
+ protocol=TcpProtocol(),
84
84
  )