maxapi-python 2.1.2__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.
- {maxapi_python-2.1.2.dist-info → maxapi_python-2.1.3.dist-info}/METADATA +1 -1
- {maxapi_python-2.1.2.dist-info → maxapi_python-2.1.3.dist-info}/RECORD +19 -18
- pymax/__init__.py +1 -1
- pymax/app.py +18 -4
- pymax/client.py +1 -4
- pymax/connection/connection.py +46 -19
- pymax/files/photo.py +4 -2
- pymax/logging.py +33 -3
- pymax/protocol/tcp/payload.py +22 -42
- pymax/protocol/tcp/protocol.py +2 -8
- pymax/types/domain/attachments/__init__.py +1 -0
- pymax/types/domain/attachments/audio.py +4 -4
- pymax/types/domain/attachments/enums.py +1 -0
- pymax/types/domain/attachments/unknown.py +37 -0
- pymax/types/domain/attachments/video.py +2 -2
- pymax/types/domain/element.py +3 -3
- pymax/types/domain/message.py +3 -1
- {maxapi_python-2.1.2.dist-info → maxapi_python-2.1.3.dist-info}/WHEEL +0 -0
- {maxapi_python-2.1.2.dist-info → maxapi_python-2.1.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
pymax/__init__.py,sha256=
|
|
2
|
-
pymax/app.py,sha256=
|
|
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=
|
|
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=
|
|
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
|
|
@@ -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=
|
|
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=
|
|
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=
|
|
93
|
-
pymax/protocol/tcp/protocol.py,sha256
|
|
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=
|
|
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
117
|
pymax/types/domain/folder.py,sha256=Z9pXMzOTfxuhUfrJpLjl5LPnVXqqFsBVfKBQm4IbGWM,2225
|
|
118
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=
|
|
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=
|
|
128
|
-
pymax/types/domain/attachments/audio.py,sha256=
|
|
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=
|
|
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/
|
|
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.
|
|
145
|
-
maxapi_python-2.1.
|
|
146
|
-
maxapi_python-2.1.
|
|
147
|
-
maxapi_python-2.1.
|
|
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,,
|
pymax/__init__.py
CHANGED
pymax/app.py
CHANGED
|
@@ -49,6 +49,7 @@ class App(Generic[ClientT]):
|
|
|
49
49
|
self._telemetry = TelemetryService(self) if config.telemetry else None
|
|
50
50
|
|
|
51
51
|
self.connection.on_event = self.on_event
|
|
52
|
+
self.connection.on_close = self.on_connection_lost
|
|
52
53
|
logger.debug(
|
|
53
54
|
"app initialized session=%s work_dir=%s auth_flow=%s",
|
|
54
55
|
config.session_name,
|
|
@@ -174,6 +175,7 @@ class App(Generic[ClientT]):
|
|
|
174
175
|
await self.dispatcher.stop_startup_tasks()
|
|
175
176
|
await self.connection.close()
|
|
176
177
|
await self.store.close()
|
|
178
|
+
|
|
177
179
|
self.started = False
|
|
178
180
|
|
|
179
181
|
async def invoke(
|
|
@@ -203,9 +205,7 @@ class App(Generic[ClientT]):
|
|
|
203
205
|
payload_keys,
|
|
204
206
|
)
|
|
205
207
|
logger.debug("Request data=%s", frame.model_dump())
|
|
206
|
-
request_timeout =
|
|
207
|
-
self.config.request_timeout if timeout is None else timeout
|
|
208
|
-
)
|
|
208
|
+
request_timeout = self.config.request_timeout if timeout is None else timeout
|
|
209
209
|
response = await self.connection.request(frame, timeout=request_timeout)
|
|
210
210
|
response_keys = sorted(response.payload.keys()) if response.payload else []
|
|
211
211
|
logger.debug(
|
|
@@ -231,9 +231,23 @@ class App(Generic[ClientT]):
|
|
|
231
231
|
except asyncio.CancelledError:
|
|
232
232
|
raise
|
|
233
233
|
except Exception as e:
|
|
234
|
-
logger.
|
|
234
|
+
logger.warning("ping loop failed; closing transport: %s", e)
|
|
235
235
|
await self.connection.fail(ConnectionError(f"Ping failed: {e}"))
|
|
236
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
|
+
|
|
237
251
|
def _build_api_error(self, response: InboundFrame) -> ApiError:
|
|
238
252
|
try:
|
|
239
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:
|
pymax/connection/connection.py
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
156
|
-
except TimeoutError as e:
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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())
|
pymax/protocol/tcp/payload.py
CHANGED
|
@@ -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(
|
|
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) ->
|
|
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,
|
|
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
|
-
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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):
|
pymax/protocol/tcp/protocol.py
CHANGED
|
@@ -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",
|
|
@@ -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
|
|
@@ -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
|
pymax/types/domain/element.py
CHANGED
|
@@ -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
|
pymax/types/domain/message.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
File without changes
|
|
File without changes
|