maxapi-python 2.1.1__py3-none-any.whl → 2.1.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxapi-python
3
- Version: 2.1.1
3
+ Version: 2.1.3
4
4
  Summary: Python wrapper для API мессенджера Max
5
5
  Project-URL: Homepage, https://github.com/MaxApiTeam/PyMax
6
6
  Project-URL: Repository, https://github.com/MaxApiTeam/PyMax
@@ -1,11 +1,11 @@
1
- pymax/__init__.py,sha256=4tc91HCbTG_Xq6M7IPdc3sF_7bpScpoXEG0CQZyvDAs,1255
2
- pymax/app.py,sha256=3FlgRT7Lj4LxoRQfSsuw1kHby5au6klhxnjHBXrvmQ0,9819
1
+ pymax/__init__.py,sha256=M6NWntSBu0SbcYNaVeUkhx9cYmTRMWTqZyybgA8LP9c,1255
2
+ pymax/app.py,sha256=4faFC8y6CHVRH_FJX_3bfFthOMqxC8fObFxw4Sb0MHY,10263
3
3
  pymax/base.py,sha256=Q6FR5K1nOaeVnYlzRgqC6_v7AWUiJJCkI4TuzCYbj-U,7255
4
- pymax/client.py,sha256=KiDEiUL49IGxRtq4-Sr3IABa8Rh1TRPq41Gv5jH2m_Q,4013
4
+ pymax/client.py,sha256=YCOZI_BIUvDKGNXBxnVErVTZ8cXr8DE8SS9UqGD85VA,3967
5
5
  pymax/client_web.py,sha256=iTwlsY75d_wg-vUGObTq7SVinp0L__2r8cvG49BtmUI,3041
6
6
  pymax/config.py,sha256=CkswnDfDjDIlYg1v0Ra3czjZvceePIaobJP51qHMdTg,8615
7
7
  pymax/exceptions.py,sha256=8pxjZsfgrMPrZfE3zedIbabqP75diEr2_6Xg3PGjiSQ,963
8
- pymax/logging.py,sha256=K0dsOMN4LPxybIvCeo5q1yWNRL-TSbu2r5IB25lhYb0,3410
8
+ pymax/logging.py,sha256=u7fJ3-O8K7DRgohDFIyCefxz7yhy_0qxGD-tD7d0ZIw,4548
9
9
  pymax/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  pymax/routers.py,sha256=UvIEWcBF1VPBmUFrubBQ7uo3DZylf4qdbnQGDkUYjyE,225
11
11
  pymax/api/__init__.py,sha256=oWC09vGaP6S7_nnTJ4y5PbTkbA4U29DhafpwIqD2QGI,356
@@ -18,8 +18,8 @@ pymax/api/auth/payloads.py,sha256=XxrVdNcz1uV6hUxDp9Dt_stENECi_eDlxuiqt44DHeo,30
18
18
  pymax/api/auth/service.py,sha256=sjZcMyP6siH0dR19eYwkhhLU09bVJD4FCNeSra2Rjac,11639
19
19
  pymax/api/auth/types.py,sha256=VrYR7rIZOrW45yKnQmC5MSe9kxgXmqPzh9VG_y1tSEs,200
20
20
  pymax/api/bots/__init__.py,sha256=4P7rM-XeeDOElEktov1Vbfo8nzjJgmyA4_pQl4WvzRQ,33
21
- pymax/api/bots/payloads.py,sha256=-vrswY_VlQrLKoqOxrFBl7pE8QG4dLQSaGOtKs4Lq1o,152
22
- pymax/api/bots/service.py,sha256=ssVty2_4WMQbecQGcIJYEeYZ0RT6VgMu5qzqgAcegWo,877
21
+ pymax/api/bots/payloads.py,sha256=aXriolCN_YvBvDZO_MPmD-fkrIVJIpVDXkOkOZVes_c,166
22
+ pymax/api/bots/service.py,sha256=ziqVlQUMFHjQydMDFyWcQMIATboQTvIdGtJhf-AK9tA,847
23
23
  pymax/api/chats/__init__.py,sha256=UMp3uKdB3tZlnWckndgF3jaU-3-49OJE18z2d0OlmIU,155
24
24
  pymax/api/chats/enums.py,sha256=FdByRiHYX9Xs7RyNabKqxGugNs9tJ3VBXwTb_43XGC8,627
25
25
  pymax/api/chats/payloads.py,sha256=Y4S2x60i3txeA75GV_pGJ3ov7pLMp4GjGuhoqlRI2ow,2824
@@ -53,7 +53,7 @@ pymax/auth/qr.py,sha256=17VTtcwNcpuzbR_1t1bpwmdDFxLniNidakOYSKjOJNc,4309
53
53
  pymax/auth/service.py,sha256=cUZxs5UW9ysSaJ-UXSOz0ug3fDo1eN0xAjO7VP1MdTU,619
54
54
  pymax/auth/sms.py,sha256=34L9Od_0RYitK9G4xxFDJAq-3vf_AfzIGfBShwVNSRk,4185
55
55
  pymax/connection/__init__.py,sha256=YTboFk9AfYkRUw2Hf4oxn63LTGV475fxp_oel1j4gas,42
56
- pymax/connection/connection.py,sha256=mLqQOE7eeIJEOE2Nx7hZibCgDar1evgo5IksGGyfzvM,6859
56
+ pymax/connection/connection.py,sha256=zuaTo117cqoOaknCXvTaDhJQlMc0-J2XBilwH9Z1BU4,7868
57
57
  pymax/connection/pending.py,sha256=qezxL019d1E6oiCmSgCSfojLj7sxR1BEGgcKBWSFhok,1328
58
58
  pymax/connection/readers/__init__.py,sha256=9g5ZbcWiHxW1kAi2ckiNqejoGHwoDwFd4WvtYQSyRzU,52
59
59
  pymax/connection/readers/base.py,sha256=FlvyMUFoZ4FORAh0vBU6_I0BnrQ9_1twpUIKz-Vkj58,126
@@ -68,7 +68,7 @@ pymax/dispatch/router.py,sha256=0R_Z-YP9yLAyWlBaBgevgHP0BmZGr4N8JIRtjob8KSA,6592
68
68
  pymax/files/__init__.py,sha256=TR0N6YXX4IHBsXQo8uiK68C-Xg3LSj48d4Zsv71CWZE,126
69
69
  pymax/files/base.py,sha256=0aksCGxmrvqEKJdhRWcuPZJfEn7RWcl5tn5gn63q1os,2689
70
70
  pymax/files/file.py,sha256=JSkuKx0fdGxzcsyMajWTosFzTlfgZYvQbh-RDp9OZyQ,2337
71
- pymax/files/photo.py,sha256=TeGBzVXFpiExhMjKCLHFjCu3DYKFZ6-jub-RZ_SiCco,3523
71
+ pymax/files/photo.py,sha256=jvHihuyghDljY9PLh3ymJfoj5KksIRBWIK3z9Dmgjuk,3604
72
72
  pymax/files/static.py,sha256=U_KfM0rbGMEWcsBnWIhIXzzk46hHYQ7KJnrGnfDLoMs,142
73
73
  pymax/files/video.py,sha256=fGXgSY5zH-voYPVjbDBnkGI1TeP-KOSq3DZEDFNnn68,2153
74
74
  pymax/formatting/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -89,8 +89,8 @@ pymax/protocol/models.py,sha256=kno-09OoPoLKBMixS9nrDkCbxcIwvU37RlZgaq5MfVo,584
89
89
  pymax/protocol/tcp/__init__.py,sha256=ivIZZ-UoT_MiRIUWTLiyeKSBOzf9XztSprHPHgoWNuI,34
90
90
  pymax/protocol/tcp/compression.py,sha256=K0xf8W_pVB-CFD0cJs3SrpTK3WXeZom3ToMo8Iu3Qug,3028
91
91
  pymax/protocol/tcp/framing.py,sha256=Zp-glGsPqYODOnkmKdf6Ux8dcjleyuPWudQdHeO5PXQ,1652
92
- pymax/protocol/tcp/payload.py,sha256=dKAriFuWoonCmfogijv7FTXEKlNPz8vOwI6xO6INJG0,4301
93
- pymax/protocol/tcp/protocol.py,sha256=T5N2qoCoYxcHqPylBqpLW3ej1Ew3XBvoKWMuXyc0qLc,2322
92
+ pymax/protocol/tcp/payload.py,sha256=86-NJ5MVvxpgD2ffQqJGaSbDhVBFhaNOH91-k1IQJk8,3515
93
+ pymax/protocol/tcp/protocol.py,sha256=-8JQe5rhXXtBMNeCWM8XJXmlnL-0lNSEUCP6LU2zU7c,2244
94
94
  pymax/protocol/ws/__init__.py,sha256=oQK0h28B6gGItf7eHanWy_XsgUA_L9Lclnuz9ZQL9YI,33
95
95
  pymax/protocol/ws/protocol.py,sha256=Ob90-jLDwGqo8ymwDPvwUnWacWyQtneKEjCThfpqbKE,1003
96
96
  pymax/session/__init__.py,sha256=natjnLjPjTvkfvSYtoiPxzmU44XJi0MpjWiOPD-On4g,100
@@ -111,37 +111,38 @@ pymax/types/domain/auth.py,sha256=8p4qX9RAJ6dIsAmoKFrfxlq6dct689qXgrngcep48a4,52
111
111
  pymax/types/domain/base.py,sha256=Q32PcKR3gtrISySowR3zPyl9KeQCoUL6lfyTR3ulMzo,623
112
112
  pymax/types/domain/bots.py,sha256=dErZZbvspznU6yUupzCs4R55pEWgoeps7NEhZFrb-Uo,333
113
113
  pymax/types/domain/chat.py,sha256=xpLutj9uCIQupAO1zu2eXzEEXL8DcQ4N6tkZQeQUsCM,18329
114
- pymax/types/domain/element.py,sha256=slMVlp5Fn1H1hFUinruuP7s2wawkSrS5c1eCeY31p-g,620
114
+ pymax/types/domain/element.py,sha256=Hks0MulMcPAhbpuUmJa5c2d9Emwy3garh1k6bd4QHKA,655
115
115
  pymax/types/domain/enums.py,sha256=eGMWnlv0mYJ8K-0FsC_wp4Co2M6YWyKI1Lx90MJ6s4g,412
116
116
  pymax/types/domain/error.py,sha256=WZoDAXf5vt8O4VYy7iEHLGBfH6hZQZ1fVkTz9rVCTN0,584
117
- pymax/types/domain/folder.py,sha256=Xe1CmWfh3BhjpPNV-aWqT2JA4sX6ya2HaC3BSDMEqL8,2445
118
- pymax/types/domain/login.py,sha256=mpGWNcFi5pwRsZbPA_GCtC1b-dreZA1hxayz5P7eL90,1451
117
+ pymax/types/domain/folder.py,sha256=Z9pXMzOTfxuhUfrJpLjl5LPnVXqqFsBVfKBQm4IbGWM,2225
118
+ pymax/types/domain/login.py,sha256=FBKpRhNPv6Zdjo6Wfl-lK8JpzpMx7zh84f1qrq6Z0og,1301
119
119
  pymax/types/domain/member.py,sha256=8gUNn4fBS_S2ap45A40lRlrDXYFMoq0Kx-dh57hMWQc,490
120
- pymax/types/domain/message.py,sha256=vb1Rmxnz145EP5_C1fpjvGLrenATN6HBivrst9M8OOc,14387
120
+ pymax/types/domain/message.py,sha256=U1ZC9E3tuEbxM0bir82VksyLzqAfLZXn1tw2AaSIJ_8,14475
121
121
  pymax/types/domain/name.py,sha256=qMIshIqgWkVuROxmiCRhwfJf8XtmhSBoEU6nXs_gEh0,523
122
122
  pymax/types/domain/presence.py,sha256=NdI2wHLxc3UOSUyTallGb6Cw4xRlh6DniW2ee-aZdWI,466
123
123
  pymax/types/domain/profile.py,sha256=taN5PjlKp4EDypVhDx89vmBDpSKh-kT-ISpu1tR2cY8,471
124
124
  pymax/types/domain/session.py,sha256=T0b0qjI65kNQOCgx3nIkaIqynD4eGKyqnscbIlPFVnQ,2002
125
125
  pymax/types/domain/sync.py,sha256=3hNN2Dwle-rD7YxJW00n5GPR6vmLdhW5GhiJsaC9ekc,3498
126
126
  pymax/types/domain/user.py,sha256=NjJfQtkRBrsmB-JGg2nsXWLvgqu3q5YKx2BALkoDrOI,5120
127
- pymax/types/domain/attachments/__init__.py,sha256=7QS5881emGVJ4YBMxHkzxYWWwRjPH9-VKWu9xzGnr8s,432
128
- pymax/types/domain/attachments/audio.py,sha256=X_GR68yDCDqRlLcO4qtZPQigfmQfkRFb9OF-TZgoDkw,1075
127
+ pymax/types/domain/attachments/__init__.py,sha256=ZVSb7sSAwabSB8A8BWF_JbooPOMIP1r6gF_NUASgGGk,471
128
+ pymax/types/domain/attachments/audio.py,sha256=I_Qq1qQ9bXsEzWwxEbQbkiY8CEjSKqGqinDFYY_5DY8,1117
129
129
  pymax/types/domain/attachments/call.py,sha256=RV-BFutymZFinzUiRdaIryqx6ubku2v6y6brc-qsHOQ,818
130
130
  pymax/types/domain/attachments/contact.py,sha256=EO-B0klgUMITL05fBgFXi99i8VzxBj3D2pLB0JBWy_M,996
131
131
  pymax/types/domain/attachments/control.py,sha256=1Zr0pM1ODZgYRX3CDw7yGzqEgb6R_JjRSQOSI_FDpDE,502
132
- pymax/types/domain/attachments/enums.py,sha256=O55YcZtpuTxu2i3MLXh0-rbwNltOkzEj9F9hygfi_hc,619
132
+ pymax/types/domain/attachments/enums.py,sha256=JYX5xoefm0BmRqVjOZs0-xQpI9bddphps_wZX34tK14,643
133
133
  pymax/types/domain/attachments/file.py,sha256=3lqsPIkg75-AmcaEbXwc1eiQHIv9JpWzMc0z4u2MupE,1595
134
134
  pymax/types/domain/attachments/photo.py,sha256=8q79OuQ0V8t-s9pZM8ClAmgJI71m-bgES4Bd5ZSNVI0,1413
135
135
  pymax/types/domain/attachments/share.py,sha256=BHJIVjTC99wfbUn8wSN4j0aSNlEpojzv87uwbmhPPt0,924
136
136
  pymax/types/domain/attachments/sticker.py,sha256=2CFqoUAuUjA6nclRXZUCBO2mKZd24BaBY3wz2XoR7kE,1530
137
- pymax/types/domain/attachments/video.py,sha256=Jdh0ZKC6R2crbQUD8LEhMnNDYIM_gCoxDazMA_DjVNU,2888
137
+ pymax/types/domain/attachments/unknown.py,sha256=4VTLLcZJJA3QS81z_yZhlSLJW4wQLQ5YkJDwzQ9Tjyw,947
138
+ pymax/types/domain/attachments/video.py,sha256=82stz0MYkypJOP0JAwNGPEV783ODMJabYxRzTqGxmuE,2909
138
139
  pymax/types/domain/attachments/keyboards/__init__.py,sha256=1X5UGBe5E_DO7e4p8_JmJEocHLzXOezutieRuQsVpRA,45
139
140
  pymax/types/domain/attachments/keyboards/inline.py,sha256=3RNubcqx7RZ1f6zfJ7JaNFCPx5mnmJ5vYsL1i7lSMAM,577
140
141
  pymax/types/events/__init__.py,sha256=fNLP_8zYRsew2k-1s8wXCJeSPm8QQ4gmiZ86B51SuCw,112
141
142
  pymax/types/events/file.py,sha256=XhpXn0Vt9OODkx1OkGb9jfQwc1uNgz1NTn_j9FZnQ8s,102
142
143
  pymax/types/events/message.py,sha256=d4dlnpu3VCtZ4vFdRXaACvydAE9sbGGkk52E48K0U_U,1248
143
144
  pymax/types/events/video.py,sha256=swBHYadmDS0SjLXGqVqRYsuMoVZ6MjD2aYR2fbM-AJc,104
144
- maxapi_python-2.1.1.dist-info/METADATA,sha256=2z2avbn6KZLKC63s-N9Gl8Wu78IsT75BMkas8p6rZi0,7637
145
- maxapi_python-2.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
146
- maxapi_python-2.1.1.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
147
- maxapi_python-2.1.1.dist-info/RECORD,,
145
+ maxapi_python-2.1.3.dist-info/METADATA,sha256=S790dHMIPc8eOIwrWMzw0pTllSB_Sxc4lR2BMrnHotM,7637
146
+ maxapi_python-2.1.3.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
147
+ maxapi_python-2.1.3.dist-info/licenses/LICENSE,sha256=hOR249ItqMdcly1A0amqEWRNRTq4Gv5NJtmQ3A5qK4E,1070
148
+ maxapi_python-2.1.3.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.30.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
pymax/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "2.1.1"
1
+ __version__ = "2.1.3"
2
2
 
3
3
 
4
4
  from .auth import (
@@ -3,5 +3,5 @@ from pymax.api.models import CamelModel
3
3
 
4
4
  class RequestInitDataPayload(CamelModel):
5
5
  bot_id: int
6
- chat_id: int
6
+ chat_id: int | None = None
7
7
  start_param: str | None = None
pymax/api/bots/service.py CHANGED
@@ -23,13 +23,9 @@ class BotsService:
23
23
  async def get_init_data(
24
24
  self,
25
25
  bot_id: int,
26
- chat_id: int,
26
+ chat_id: int | None = None,
27
27
  start_param: str | None = None,
28
28
  ) -> InitData:
29
- frame = RequestInitDataPayload(
30
- bot_id=bot_id, chat_id=chat_id, start_param=start_param
31
- )
32
- response = await self.app.invoke(
33
- Opcode.WEB_APP_INIT_DATA, frame.to_payload()
34
- )
29
+ frame = RequestInitDataPayload(bot_id=bot_id, chat_id=chat_id, start_param=start_param)
30
+ response = await self.app.invoke(Opcode.WEB_APP_INIT_DATA, frame.to_payload())
35
31
  return require_payload_model(response, InitData)
pymax/app.py CHANGED
@@ -33,9 +33,7 @@ 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(
37
- config.work_dir, config.session_name
38
- )
36
+ self.store = self.config.store or SessionStore(config.work_dir, config.session_name)
39
37
  self.auth_flow = auth_flow
40
38
 
41
39
  self.me: Profile | None = None
@@ -51,6 +49,7 @@ class App(Generic[ClientT]):
51
49
  self._telemetry = TelemetryService(self) if config.telemetry else None
52
50
 
53
51
  self.connection.on_event = self.on_event
52
+ self.connection.on_close = self.on_connection_lost
54
53
  logger.debug(
55
54
  "app initialized session=%s work_dir=%s auth_flow=%s",
56
55
  config.session_name,
@@ -76,18 +75,14 @@ class App(Generic[ClientT]):
76
75
  await self.connection.open()
77
76
 
78
77
  handshake_device_id = (
79
- session_data.device_id
80
- if session_data
81
- else self.config.device.device_id
78
+ session_data.device_id if session_data else self.config.device.device_id
82
79
  )
83
80
  logger.debug("running handshake")
84
81
  await self.handshake(handshake_device_id)
85
82
  except (ConnectionError, EOFError, OSError, TimeoutError) as e:
86
83
  logger.exception("failed to connect or handshake")
87
84
  await self.connection.close()
88
- raise ConnectionError(
89
- f"Failed to connect and handshake: {e}"
90
- ) from e
85
+ raise ConnectionError(f"Failed to connect and handshake: {e}") from e
91
86
 
92
87
  self._ping_task = asyncio.create_task(self._ping_loop())
93
88
 
@@ -108,9 +103,7 @@ class App(Generic[ClientT]):
108
103
 
109
104
  if not auth_result.token:
110
105
  logger.error("authentication finished without token")
111
- raise RuntimeError(
112
- "Authentication failed: no token received"
113
- )
106
+ raise RuntimeError("Authentication failed: no token received")
114
107
 
115
108
  await self.store.save_session(
116
109
  session_data := SessionInfo(
@@ -135,7 +128,7 @@ class App(Generic[ClientT]):
135
128
  self.config.device.user_agent,
136
129
  )
137
130
 
138
- if response.token != self.session.token:
131
+ if response.token is not None and response.token != self.session.token:
139
132
  await self.store.update_token(self.session.token, response.token)
140
133
  self.session.token = response.token
141
134
 
@@ -182,6 +175,7 @@ class App(Generic[ClientT]):
182
175
  await self.dispatcher.stop_startup_tasks()
183
176
  await self.connection.close()
184
177
  await self.store.close()
178
+
185
179
  self.started = False
186
180
 
187
181
  async def invoke(
@@ -189,7 +183,7 @@ class App(Generic[ClientT]):
189
183
  opcode: int,
190
184
  payload: dict[str, Any],
191
185
  cmd: int = Command.REQUEST,
192
- timeout: float | None = 30.0,
186
+ timeout: float | None = None,
193
187
  compress: bool = False,
194
188
  ) -> InboundFrame:
195
189
  seq = self.connection.next_seq()
@@ -211,10 +205,9 @@ class App(Generic[ClientT]):
211
205
  payload_keys,
212
206
  )
213
207
  logger.debug("Request data=%s", frame.model_dump())
214
- response = await self.connection.request(frame, timeout=timeout)
215
- response_keys = (
216
- sorted(response.payload.keys()) if response.payload else []
217
- )
208
+ request_timeout = self.config.request_timeout if timeout is None else timeout
209
+ response = await self.connection.request(frame, timeout=request_timeout)
210
+ response_keys = sorted(response.payload.keys()) if response.payload else []
218
211
  logger.debug(
219
212
  "response opcode=%s cmd=%s seq=%s payload_keys=%s",
220
213
  response.opcode,
@@ -238,9 +231,23 @@ class App(Generic[ClientT]):
238
231
  except asyncio.CancelledError:
239
232
  raise
240
233
  except Exception as e:
241
- logger.exception("ping loop failed; closing transport")
234
+ logger.warning("ping loop failed; closing transport: %s", e)
242
235
  await self.connection.fail(ConnectionError(f"Ping failed: {e}"))
243
236
 
237
+ def on_connection_lost(self, exc: Exception | None = None) -> None:
238
+ if self.started:
239
+ logger.warning("connection lost; marking app as stopped: %s", exc)
240
+
241
+ self.started = False
242
+
243
+ task = self._ping_task
244
+ if task is None or task.done():
245
+ return
246
+
247
+ current_task = asyncio.current_task()
248
+ if task is not current_task:
249
+ task.cancel()
250
+
244
251
  def _build_api_error(self, response: InboundFrame) -> ApiError:
245
252
  try:
246
253
  error = MaxApiError.model_validate(response.payload)
pymax/client.py CHANGED
@@ -66,10 +66,7 @@ class Client(BaseClient["Client"]):
66
66
 
67
67
  self._config = self._build_config(
68
68
  phone=phone,
69
- user_agent=(
70
- self.extra_config.user_agent
71
- or self.extra_config.generate_user_agent()
72
- ),
69
+ user_agent=(self.extra_config.user_agent or self.extra_config.generate_user_agent()),
73
70
  )
74
71
 
75
72
  if auth_flow is None:
@@ -20,16 +20,19 @@ class ConnectionManager:
20
20
  transport: Transport,
21
21
  protocol: BaseProtocol,
22
22
  on_event: Callable[[InboundFrame], Awaitable[None]] | None = None,
23
+ on_close: Callable[[Exception | None], None] | None = None,
23
24
  ) -> None:
24
25
  self.reader = reader
25
26
  self.transport = transport
26
27
  self.protocol = protocol
27
28
  self.on_event = on_event
29
+ self.on_close = on_close
28
30
 
29
31
  self.requests = PendingRequests()
30
32
 
31
33
  self._is_open = False
32
34
  self._connection_lost = False
35
+ self._close_reported = False
33
36
  self._seq = -1
34
37
 
35
38
  self._recv_task: asyncio.Task[None] | None = None
@@ -44,6 +47,7 @@ class ConnectionManager:
44
47
  await self.transport.connect()
45
48
  self._is_open = True
46
49
  self._connection_lost = False
50
+ self._close_reported = False
47
51
 
48
52
  self._recv_task = asyncio.create_task(self._recv_loop())
49
53
  logger.debug("receive loop started")
@@ -80,7 +84,7 @@ class ConnectionManager:
80
84
  self._connection_lost = True
81
85
  self.requests.cancel_all(exc=exc)
82
86
  await self.transport.close()
83
- self._is_open = False
87
+ self._mark_closed(exc)
84
88
 
85
89
  async def send(self, frame: OutboundFrame) -> None:
86
90
  if not self._is_open:
@@ -116,20 +120,38 @@ class ConnectionManager:
116
120
  )
117
121
  await self.transport.send(raw)
118
122
  return await asyncio.wait_for(future, timeout)
119
- except Exception as e:
123
+ except asyncio.CancelledError:
124
+ self.requests.discard(frame.seq)
125
+ raise
126
+ except (ConnectionError, EOFError, OSError, TimeoutError) as e:
127
+ logger.warning(
128
+ "request failed seq=%s opcode=%s error=%s",
129
+ frame.seq,
130
+ frame.opcode,
131
+ e,
132
+ )
133
+ self.requests.discard(frame.seq)
134
+ raise
135
+ except Exception:
120
136
  logger.exception(
121
137
  "request failed seq=%s opcode=%s",
122
138
  frame.seq,
123
139
  frame.opcode,
124
140
  )
125
- self.requests.reject(frame.seq, e)
141
+ self.requests.discard(frame.seq)
126
142
  raise
127
143
 
128
144
  async def wait_closed(self) -> None:
129
145
  if not self._recv_task:
130
146
  return
131
147
 
132
- await self._recv_task
148
+ try:
149
+ await self._recv_task
150
+ except Exception as e:
151
+ if self._connection_lost:
152
+ raise ConnectionError("Connection lost") from e
153
+ raise
154
+
133
155
  if self._connection_lost:
134
156
  raise ConnectionError("Connection lost")
135
157
 
@@ -147,27 +169,23 @@ class ConnectionManager:
147
169
  await self._handle_inbound(model)
148
170
 
149
171
  except EOFError:
172
+ exc = ConnectionError("Connection closed by the server")
150
173
  logger.warning("connection closed by server")
151
- self.requests.cancel_all(
152
- exc=ConnectionError("Connection closed by the server")
153
- )
174
+ self.requests.cancel_all(exc=exc)
154
175
  self._connection_lost = True
155
- self._is_open = False
156
- except TimeoutError as e:
157
- logger.exception("connection timed out")
158
- self.requests.cancel_all(
159
- exc=ConnectionError("Connection timed out")
160
- )
176
+ self._mark_closed(exc)
177
+ except (ConnectionError, OSError, TimeoutError) as e:
178
+ exc = ConnectionError(f"Connection error: {e}")
179
+ logger.warning("connection closed while reading payload: %s", e)
180
+ self.requests.cancel_all(exc=exc)
161
181
  self._connection_lost = True
162
- self._is_open = False
163
- raise e
182
+ self._mark_closed(exc)
164
183
  except Exception as e:
184
+ exc = ConnectionError(f"Connection error: {e}")
165
185
  logger.exception("connection receive loop failed")
166
- self.requests.cancel_all(
167
- exc=ConnectionError(f"Connection error: {e}")
168
- )
186
+ self.requests.cancel_all(exc=exc)
169
187
  self._connection_lost = True
170
- self._is_open = False
188
+ self._mark_closed(exc)
171
189
  raise e
172
190
 
173
191
  async def _handle_inbound(self, frame: InboundFrame) -> None:
@@ -210,6 +228,15 @@ class ConnectionManager:
210
228
  self._seq = (self._seq + 1) % 0x10000
211
229
  return self._seq
212
230
 
231
+ def _mark_closed(self, exc: Exception | None = None) -> None:
232
+ self._is_open = False
233
+ if self._close_reported:
234
+ return
235
+
236
+ self._close_reported = True
237
+ if self.on_close:
238
+ self.on_close(exc)
239
+
213
240
  @property
214
241
  def is_open(self) -> bool:
215
242
  return self._is_open
pymax/files/photo.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import mimetypes
2
2
  from collections.abc import AsyncGenerator
3
3
  from pathlib import Path
4
+ from urllib.parse import urlsplit
4
5
 
5
6
  from .base import BaseFile
6
7
  from .static import ALLOWED_EXTENSIONS
@@ -66,12 +67,13 @@ class Photo(BaseFile):
66
67
  raise ValueError(msg)
67
68
  return (extension[1:], ("image/" + extension[1:]).lower())
68
69
  if self.url:
69
- extension = Path(self.url).suffix.lower()
70
+ url_path = urlsplit(self.url).path
71
+ extension = Path(url_path).suffix.lower()
70
72
  if extension not in ALLOWED_EXTENSIONS:
71
73
  msg = f"Invalid photo extension: {extension}. Allowed: {ALLOWED_EXTENSIONS}"
72
74
  raise ValueError(msg)
73
75
 
74
- mime_type = mimetypes.guess_type(self.url)[0]
76
+ mime_type = mimetypes.guess_type(url_path)[0]
75
77
 
76
78
  if not mime_type or not mime_type.startswith("image/"):
77
79
  msg = f"URL does not appear to be an image: {self.url}"
pymax/logging.py CHANGED
@@ -4,6 +4,7 @@ import sys
4
4
  from typing import TextIO
5
5
 
6
6
  DATE_FORMAT = "%H:%M:%S"
7
+ PYMAX_HANDLER_ATTR = "_pymax_pretty_handler"
7
8
 
8
9
  RESET = "\x1b[0m"
9
10
  DIM = "\x1b[2m"
@@ -56,17 +57,22 @@ def configure_logging(
56
57
  *,
57
58
  stream: TextIO | None = None,
58
59
  use_colors: bool | None = None,
60
+ force: bool = False,
59
61
  ) -> None:
60
62
  """Настраивает pretty-логи для logger-а ``pymax``.
61
63
 
62
64
  Обычно уровень логов задают через ``ExtraConfig(log_level="DEBUG")``.
63
- Вызывайте эту функцию вручную, если хотите управлять stream или цветами.
65
+ PyMax ставит свой handler только если приложение еще не настроило logging.
66
+ Вызывайте эту функцию с ``force=True``, если хотите принудительно включить
67
+ pretty-логи PyMax.
64
68
 
65
69
  Args:
66
70
  level: Уровень логирования: строка вроде ``"DEBUG"`` или число из
67
71
  модуля ``logging``.
68
72
  stream: Поток для вывода. По умолчанию ``sys.stderr``.
69
73
  use_colors: Включить ANSI-цвета. Если ``None``, определяется по TTY.
74
+ force: Заменить существующие handler-ы logger-а ``pymax`` на pretty
75
+ handler PyMax.
70
76
 
71
77
  Returns:
72
78
  ``None``.
@@ -84,17 +90,25 @@ def configure_logging(
84
90
  use_colors = hasattr(stream, "isatty") and stream.isatty()
85
91
 
86
92
  logger = logging.getLogger("pymax")
93
+ level_value = _normalize_level(level)
94
+ logger.setLevel(level_value)
95
+
96
+ if not force and _logging_already_configured(logger):
97
+ if logging.getLogger().handlers and not _has_non_null_handlers(logger):
98
+ logger.propagate = True
99
+ return
100
+
87
101
  logger.handlers.clear()
88
- logger.setLevel(_normalize_level(level))
89
102
  logger.propagate = False
90
103
 
91
104
  handler = logging.StreamHandler(stream)
92
- handler.setLevel(_normalize_level(level))
105
+ handler.setLevel(level_value)
93
106
  handler.setFormatter(
94
107
  PrettyFormatter(
95
108
  use_colors=use_colors,
96
109
  )
97
110
  )
111
+ setattr(handler, PYMAX_HANDLER_ATTR, True)
98
112
 
99
113
  logger.addHandler(handler)
100
114
 
@@ -126,4 +140,20 @@ def _strip_ansi(text: str) -> str:
126
140
  return re.sub(r"\x1b\[[0-9;]*m", "", text)
127
141
 
128
142
 
143
+ def _logging_already_configured(logger: logging.Logger) -> bool:
144
+ return bool(logging.getLogger().handlers or _has_external_handlers(logger))
145
+
146
+
147
+ def _has_non_null_handlers(logger: logging.Logger) -> bool:
148
+ return any(not isinstance(handler, logging.NullHandler) for handler in logger.handlers)
149
+
150
+
151
+ def _has_external_handlers(logger: logging.Logger) -> bool:
152
+ return any(
153
+ not isinstance(handler, logging.NullHandler)
154
+ and not getattr(handler, PYMAX_HANDLER_ATTR, False)
155
+ for handler in logger.handlers
156
+ )
157
+
158
+
129
159
  logging.getLogger("pymax").addHandler(logging.NullHandler())
@@ -15,10 +15,7 @@ class MsgpackPayloadCodec:
15
15
  if isinstance(value, Enum):
16
16
  return value.value
17
17
  if isinstance(value, dict):
18
- return {
19
- self._to_msgpack_value(k): self._to_msgpack_value(v)
20
- for k, v in value.items()
21
- }
18
+ return {self._to_msgpack_value(k): self._to_msgpack_value(v) for k, v in value.items()}
22
19
  if isinstance(value, list):
23
20
  return [self._to_msgpack_value(item) for item in value]
24
21
  if isinstance(value, tuple):
@@ -28,56 +25,42 @@ class MsgpackPayloadCodec:
28
25
  def encode(self, payload: object) -> bytes:
29
26
  if payload is None:
30
27
  return b""
31
- return (
32
- msgpack.packb(self._to_msgpack_value(payload), use_bin_type=True)
33
- or b""
34
- )
28
+ return msgpack.packb(self._to_msgpack_value(payload), use_bin_type=True) or b""
35
29
 
36
- def _unpack_stream(self, payload_bytes: bytes, *, raw: bool) -> list[Any]:
30
+ def _unpack_stream(
31
+ self, payload_bytes: bytes, *, raw: bool
32
+ ) -> list[Any]: # TODO: deprecate? idk
37
33
  unpacker = msgpack.Unpacker(raw=raw, strict_map_key=False)
38
34
  unpacker.feed(payload_bytes)
39
35
  return list(unpacker)
40
36
 
41
- def decode(self, payload_bytes: bytes) -> dict[Any, Any]:
37
+ def decode(self, payload_bytes: bytes) -> Any:
42
38
  if not payload_bytes:
43
39
  return {}
44
40
 
45
41
  try:
46
42
  return msgpack.unpackb(
47
- payload_bytes, raw=False, strict_map_key=False
43
+ payload_bytes,
44
+ raw=False,
45
+ strict_map_key=False,
48
46
  )
49
- except msgpack.exceptions.ExtraData as e:
50
- if isinstance(e.unpacked, dict):
51
- logger.debug(
52
- "msgpack payload has trailing data unpacked_type=%s extra_bytes=%s extra_head=%s",
53
- type(e.unpacked).__name__,
54
- len(e.extra),
55
- e.extra[:16].hex(),
56
- )
57
- return e.unpacked
58
47
 
59
- try:
60
- values = self._unpack_stream(payload_bytes, raw=False)
61
- except UnicodeDecodeError:
62
- values = self._unpack_stream(payload_bytes, raw=True)
48
+ except msgpack.exceptions.ExtraData as e:
63
49
  logger.debug(
64
- "msgpack payload has extra data objects=%s extra_bytes=%s",
65
- [type(value).__name__ for value in values],
50
+ "msgpack extra data: unpacked_type=%s extra_len=%s extra_head=%s payload_head=%s",
51
+ type(e.unpacked).__name__,
66
52
  len(e.extra),
53
+ e.extra[:64].hex(),
54
+ payload_bytes[:128].hex(),
67
55
  )
68
- for value in values:
69
- if isinstance(value, dict):
70
- return value
71
- raise
72
- except UnicodeDecodeError:
73
- values = self._unpack_stream(payload_bytes, raw=True)
74
- logger.debug(
75
- "msgpack payload decoded with raw bytes objects=%s",
76
- [type(value).__name__ for value in values],
56
+ return e.unpacked
57
+
58
+ except Exception:
59
+ logger.exception(
60
+ "msgpack decode failed: payload_len=%s payload_head=%s",
61
+ len(payload_bytes),
62
+ payload_bytes[:128].hex(),
77
63
  )
78
- for value in values:
79
- if isinstance(value, dict):
80
- return value
81
64
  raise
82
65
 
83
66
 
@@ -93,10 +76,7 @@ class TcpPayloadDecoder:
93
76
 
94
77
  def _normalize_keys(self, obj: Any) -> Any:
95
78
  if isinstance(obj, dict):
96
- return {
97
- self._normalize_key(k): self._normalize_keys(v)
98
- for k, v in obj.items()
99
- }
79
+ return {self._normalize_key(k): self._normalize_keys(v) for k, v in obj.items()}
100
80
  if isinstance(obj, list):
101
81
  return [self._normalize_keys(item) for item in obj]
102
82
  if isinstance(obj, tuple):
@@ -25,11 +25,7 @@ class TcpProtocol(BaseProtocol):
25
25
  )
26
26
 
27
27
  def encode(self, frame: OutboundFrame) -> bytes:
28
- payload_bytes = (
29
- self.serializer.encode(frame.payload)
30
- if frame.payload is not None
31
- else b""
32
- )
28
+ payload_bytes = self.serializer.encode(frame.payload) if frame.payload is not None else b""
33
29
 
34
30
  flags = 0
35
31
 
@@ -52,9 +48,7 @@ class TcpProtocol(BaseProtocol):
52
48
 
53
49
  packed_packet = self.framer.unpack(raw)
54
50
  if not packed_packet:
55
- return InboundFrame(
56
- opcode=0, cmd=0, seq=None, payload=None, raw=None
57
- )
51
+ return InboundFrame(opcode=0, cmd=0, seq=None, payload=None, raw=None)
58
52
 
59
53
  logger.debug(
60
54
  "tcp frame decoded header ver=%s cmd=%s seq=%s opcode=%s flags=%s payload_len=%s",
@@ -8,4 +8,5 @@ from .keyboards import InlineKeyboardAttachment
8
8
  from .photo import PhotoAttachment
9
9
  from .share import ShareAttachment
10
10
  from .sticker import StickerAttachment
11
+ from .unknown import UnknownAttachment
11
12
  from .video import VideoAttachment, VideoRequest
@@ -11,9 +11,9 @@ class AudioAttachment(CamelModel):
11
11
  """Аудио-вложение сообщения.
12
12
 
13
13
  :ivar duration: Длительность аудио.
14
- :vartype duration: int
14
+ :vartype duration: int | None
15
15
  :ivar audio_id: ID аудио.
16
- :vartype audio_id: int
16
+ :vartype audio_id: int | None
17
17
  :ivar wave: Данные waveform.
18
18
  :vartype wave: str | None
19
19
  :ivar transcription_status: Статус транскрибации.
@@ -26,8 +26,8 @@ class AudioAttachment(CamelModel):
26
26
  :vartype token: str | None
27
27
  """
28
28
 
29
- duration: int
30
- audio_id: int
29
+ duration: int | None = None
30
+ audio_id: int | None = None
31
31
  wave: str | None = None
32
32
  transcription_status: TranscriptionStatus | None = None
33
33
  url: str | None = None
@@ -14,6 +14,7 @@ class AttachmentType(str, Enum):
14
14
  CALL = "CALL"
15
15
  SHARE = "SHARE"
16
16
  INLINE_KEYBOARD = "INLINE_KEYBOARD"
17
+ UNKNOWN = "UNKNOWN"
17
18
 
18
19
 
19
20
  class TranscriptionStatus(str, Enum):
@@ -0,0 +1,37 @@
1
+ from typing import Any
2
+
3
+ from pydantic import Field, model_validator
4
+
5
+ from pymax.types.domain.base import CamelModel
6
+
7
+ from .enums import AttachmentType
8
+
9
+ KNOWN_ATTACHMENT_TYPES = {
10
+ attachment_type.value
11
+ for attachment_type in AttachmentType
12
+ if attachment_type != AttachmentType.UNKNOWN
13
+ }
14
+
15
+
16
+ class UnknownAttachment(CamelModel):
17
+ """Вложение неизвестного типа.
18
+
19
+ :ivar type: Тип вложения.
20
+ :vartype type: str
21
+ """
22
+
23
+ type: str = Field(alias="_type")
24
+
25
+ @model_validator(mode="before")
26
+ @classmethod
27
+ def reject_known_attachment_type(cls, value: Any) -> Any:
28
+ if not isinstance(value, dict):
29
+ return value
30
+
31
+ attachment_type = value.get("_type", value.get("type"))
32
+ if attachment_type in KNOWN_ATTACHMENT_TYPES:
33
+ raise ValueError(
34
+ "Known attachment type should be parsed by its own model"
35
+ )
36
+
37
+ return value
@@ -31,7 +31,7 @@ class VideoAttachment(CamelModel):
31
31
  :ivar video_id: ID видео.
32
32
  :vartype video_id: int
33
33
  :ivar duration: Длительность видео.
34
- :vartype duration: int
34
+ :vartype duration: int | None
35
35
  :ivar preview_data: Данные превью.
36
36
  :vartype preview_data: bytes
37
37
  :ivar type: Тип вложения.
@@ -47,7 +47,7 @@ class VideoAttachment(CamelModel):
47
47
  height: int
48
48
  width: int
49
49
  video_id: int
50
- duration: int
50
+ duration: int | None = None
51
51
  preview_data: bytes
52
52
  type: Literal[AttachmentType.VIDEO] = Field(alias="_type")
53
53
  thumbnail: str
@@ -4,7 +4,7 @@ from .base import CamelModel
4
4
 
5
5
 
6
6
  class ElementAttributes(CamelModel):
7
- url: str
7
+ url: str | None = None
8
8
 
9
9
 
10
10
  class Element(CamelModel):
@@ -15,10 +15,10 @@ class Element(CamelModel):
15
15
  :ivar from_: Начальная позиция элемента.
16
16
  :vartype from_: int | None
17
17
  :ivar length: Длина элемента.
18
- :vartype length: int
18
+ :vartype length: int | None
19
19
  """
20
20
 
21
21
  type: str
22
22
  from_: int | None = Field(serialization_alias="from", default=None)
23
- length: int
23
+ length: int | None = None
24
24
  attributes: ElementAttributes | None = None
@@ -1,8 +1,6 @@
1
- from collections.abc import Iterator
2
1
  from typing import Any
3
2
 
4
3
  from pydantic import Field
5
- from typing_extensions import override
6
4
 
7
5
  from .base import CamelModel
8
6
 
@@ -68,7 +66,3 @@ class FolderList(CamelModel):
68
66
  folders: list[Folder] = Field(default_factory=list)
69
67
  all_filter_exclude_folders: list[Any] = Field(default_factory=list)
70
68
  folder_sync: int = 0
71
-
72
- @override
73
- def __iter__(self) -> Iterator[Folder]: # pyright: ignore[reportIncompatibleMethodOverride]
74
- yield from self.folders
@@ -16,11 +16,9 @@ class LoginConfig(CamelModel):
16
16
  class LoginResponse(CamelModel):
17
17
  chats: list[Chat] = Field(default_factory=list)
18
18
  profile: Profile
19
- messages: dict[int, list[Message]] = Field(
20
- default_factory=dict
21
- ) # chat_id -> [message]
19
+ messages: dict[int, list[Message]] = Field(default_factory=dict) # chat_id -> [message]
22
20
  contacts: list[User | None] = Field(default_factory=list)
23
- token: str
21
+ token: str | None = None
24
22
  time: int | None = None
25
23
  config: LoginConfig | None = None
26
24
 
@@ -29,19 +27,9 @@ class LoginResponse(CamelModel):
29
27
  config_hash = self.config.hash if self.config is not None else None
30
28
 
31
29
  return SyncState(
32
- chats_sync=(
33
- sync_time if sync_time is not None else current.chats_sync
34
- ),
35
- contacts_sync=(
36
- sync_time if sync_time is not None else current.contacts_sync
37
- ),
38
- drafts_sync=(
39
- sync_time if sync_time is not None else current.drafts_sync
40
- ),
41
- presence_sync=(
42
- sync_time if sync_time is not None else current.presence_sync
43
- ),
44
- config_hash=(
45
- config_hash if config_hash is not None else current.config_hash
46
- ),
30
+ chats_sync=(sync_time if sync_time is not None else current.chats_sync),
31
+ contacts_sync=(sync_time if sync_time is not None else current.contacts_sync),
32
+ drafts_sync=(sync_time if sync_time is not None else current.drafts_sync),
33
+ presence_sync=(sync_time if sync_time is not None else current.presence_sync),
34
+ config_hash=(config_hash if config_hash is not None else current.config_hash),
47
35
  )
@@ -15,6 +15,7 @@ from pymax.types.domain import (
15
15
  PhotoAttachment,
16
16
  ShareAttachment,
17
17
  StickerAttachment,
18
+ UnknownAttachment,
18
19
  VideoAttachment,
19
20
  )
20
21
 
@@ -26,7 +27,7 @@ if TYPE_CHECKING:
26
27
  from pymax.api.messages.service import MessageService
27
28
 
28
29
 
29
- Attachment: TypeAlias = Annotated[
30
+ KnownAttachment: TypeAlias = Annotated[
30
31
  PhotoAttachment
31
32
  | VideoAttachment
32
33
  | FileAttachment
@@ -39,6 +40,7 @@ Attachment: TypeAlias = Annotated[
39
40
  | CallAttachment,
40
41
  Field(discriminator="type"),
41
42
  ]
43
+ Attachment: TypeAlias = KnownAttachment | UnknownAttachment
42
44
  SendAttachment: TypeAlias = Photo | File | Video
43
45
  SendAttachments: TypeAlias = list[SendAttachment] | None
44
46