maxapi-python 0.1.2__py3-none-any.whl → 1.0.1__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-0.1.2.dist-info → maxapi_python-1.0.1.dist-info}/METADATA +3 -1
- maxapi_python-1.0.1.dist-info/RECORD +27 -0
- {maxapi_python-0.1.2.dist-info → maxapi_python-1.0.1.dist-info}/licenses/LICENSE +21 -21
- pymax/__init__.py +55 -55
- pymax/core.py +170 -156
- pymax/crud.py +99 -99
- pymax/exceptions.py +29 -20
- pymax/files.py +86 -85
- pymax/filters.py +38 -38
- pymax/interfaces.py +73 -67
- pymax/mixins/__init__.py +20 -18
- pymax/mixins/auth.py +81 -81
- pymax/mixins/channel.py +25 -25
- pymax/mixins/group.py +220 -220
- pymax/mixins/handler.py +60 -60
- pymax/mixins/message.py +293 -293
- pymax/mixins/self.py +38 -38
- pymax/mixins/telemetry.py +114 -0
- pymax/mixins/user.py +82 -82
- pymax/mixins/websocket.py +262 -242
- pymax/models.py +8 -8
- pymax/navigation.py +185 -0
- pymax/payloads.py +195 -175
- pymax/static.py +210 -210
- pymax/types.py +434 -432
- pymax/utils.py +38 -38
- maxapi_python-0.1.2.dist-info/RECORD +0 -25
- {maxapi_python-0.1.2.dist-info → maxapi_python-1.0.1.dist-info}/WHEEL +0 -0
pymax/mixins/websocket.py
CHANGED
@@ -1,242 +1,262 @@
|
|
1
|
-
import asyncio
|
2
|
-
import json
|
3
|
-
from typing import Any
|
4
|
-
|
5
|
-
import websockets
|
6
|
-
|
7
|
-
|
8
|
-
from pymax.
|
9
|
-
from pymax.
|
10
|
-
from pymax.
|
11
|
-
from pymax.
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
self.
|
57
|
-
self.
|
58
|
-
self.
|
59
|
-
self.
|
60
|
-
self.
|
61
|
-
self.
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
data.get("
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
1
|
+
import asyncio
|
2
|
+
import json
|
3
|
+
from typing import Any
|
4
|
+
|
5
|
+
import websockets
|
6
|
+
from typing_extensions import override
|
7
|
+
|
8
|
+
from pymax.exceptions import LoginError, WebSocketNotConnectedError
|
9
|
+
from pymax.interfaces import ClientProtocol
|
10
|
+
from pymax.payloads import BaseWebSocketMessage, SyncPayload
|
11
|
+
from pymax.static import ChatType, Constants, Opcode
|
12
|
+
from pymax.types import Channel, Chat, Dialog, Me, Message
|
13
|
+
|
14
|
+
|
15
|
+
class WebSocketMixin(ClientProtocol):
|
16
|
+
@property
|
17
|
+
def ws(self) -> websockets.ClientConnection:
|
18
|
+
if self._ws is None or not self.is_connected:
|
19
|
+
self.logger.critical("WebSocket not connected when access attempted")
|
20
|
+
raise WebSocketNotConnectedError
|
21
|
+
return self._ws
|
22
|
+
|
23
|
+
def _make_message(
|
24
|
+
self, opcode: int, payload: dict[str, Any], cmd: int = 0
|
25
|
+
) -> dict[str, Any]:
|
26
|
+
self._seq += 1
|
27
|
+
|
28
|
+
msg = BaseWebSocketMessage(
|
29
|
+
ver=11,
|
30
|
+
cmd=cmd,
|
31
|
+
seq=self._seq,
|
32
|
+
opcode=opcode,
|
33
|
+
payload=payload,
|
34
|
+
).model_dump(by_alias=True)
|
35
|
+
|
36
|
+
self.logger.debug(
|
37
|
+
"make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq
|
38
|
+
)
|
39
|
+
return msg
|
40
|
+
|
41
|
+
async def _send_interactive_ping(self) -> None:
|
42
|
+
while self.is_connected:
|
43
|
+
try:
|
44
|
+
await self._send_and_wait(
|
45
|
+
opcode=1,
|
46
|
+
payload={"interactive": True},
|
47
|
+
cmd=0,
|
48
|
+
)
|
49
|
+
self.logger.debug("Interactive ping sent successfully")
|
50
|
+
except Exception:
|
51
|
+
self.logger.warning("Interactive ping failed", exc_info=True)
|
52
|
+
await asyncio.sleep(30)
|
53
|
+
|
54
|
+
async def _connect(self, user_agent: dict[str, Any]) -> dict[str, Any]:
|
55
|
+
try:
|
56
|
+
self.logger.info("Connecting to WebSocket %s", self.uri)
|
57
|
+
self._ws = await websockets.connect(self.uri, origin="https://web.max.ru")
|
58
|
+
self.is_connected = True
|
59
|
+
self._incoming = asyncio.Queue()
|
60
|
+
self._pending = {}
|
61
|
+
self._recv_task = asyncio.create_task(self._recv_loop())
|
62
|
+
self.logger.info("WebSocket connected, starting handshake")
|
63
|
+
return await self._handshake(user_agent)
|
64
|
+
except Exception as e:
|
65
|
+
self.logger.error("Failed to connect: %s", e, exc_info=True)
|
66
|
+
raise ConnectionError(f"Failed to connect: {e}")
|
67
|
+
|
68
|
+
async def _handshake(self, user_agent: dict[str, Any]) -> dict[str, Any]:
|
69
|
+
try:
|
70
|
+
self.logger.debug(
|
71
|
+
"Sending handshake with user_agent keys=%s", list(user_agent.keys())
|
72
|
+
)
|
73
|
+
resp = await self._send_and_wait(
|
74
|
+
opcode=Opcode.SESSION_INIT,
|
75
|
+
payload={"deviceId": str(self._device_id), "userAgent": user_agent},
|
76
|
+
)
|
77
|
+
self.logger.info("Handshake completed")
|
78
|
+
return resp
|
79
|
+
except Exception as e:
|
80
|
+
self.logger.error("Handshake failed: %s", e, exc_info=True)
|
81
|
+
raise ConnectionError(f"Handshake failed: {e}")
|
82
|
+
|
83
|
+
async def _recv_loop(self) -> None:
|
84
|
+
if self._ws is None:
|
85
|
+
self.logger.warning("Recv loop started without websocket instance")
|
86
|
+
return
|
87
|
+
|
88
|
+
self.logger.debug("Receive loop started")
|
89
|
+
while True:
|
90
|
+
try:
|
91
|
+
raw = await self._ws.recv()
|
92
|
+
try:
|
93
|
+
data = json.loads(raw)
|
94
|
+
except Exception:
|
95
|
+
self.logger.warning("JSON parse error", exc_info=True)
|
96
|
+
continue
|
97
|
+
|
98
|
+
seq = data.get("seq")
|
99
|
+
fut = self._pending.get(seq) if isinstance(seq, int) else None
|
100
|
+
|
101
|
+
if fut and not fut.done():
|
102
|
+
fut.set_result(data)
|
103
|
+
self.logger.debug("Matched response for pending seq=%s", seq)
|
104
|
+
else:
|
105
|
+
if self._incoming is not None:
|
106
|
+
try:
|
107
|
+
self._incoming.put_nowait(data)
|
108
|
+
except asyncio.QueueFull:
|
109
|
+
self.logger.warning(
|
110
|
+
"Incoming queue full; dropping message seq=%s",
|
111
|
+
data.get("seq"),
|
112
|
+
)
|
113
|
+
|
114
|
+
if (
|
115
|
+
data.get("opcode") == Opcode.NOTIF_MESSAGE
|
116
|
+
and self._on_message_handlers
|
117
|
+
):
|
118
|
+
try:
|
119
|
+
for handler, filter in self._on_message_handlers:
|
120
|
+
payload = data.get("payload", {})
|
121
|
+
msg = Message.from_dict(payload.get("message"))
|
122
|
+
if msg:
|
123
|
+
if msg.status:
|
124
|
+
continue # TODO: заглушка! сделать отдельный хендлер
|
125
|
+
if filter:
|
126
|
+
if filter.match(msg):
|
127
|
+
result = handler(msg)
|
128
|
+
else:
|
129
|
+
continue
|
130
|
+
else:
|
131
|
+
result = handler(msg)
|
132
|
+
if asyncio.iscoroutine(result):
|
133
|
+
task = asyncio.create_task(result)
|
134
|
+
self._background_tasks.add(task)
|
135
|
+
task.add_done_callback(
|
136
|
+
lambda t: self._background_tasks.discard(t)
|
137
|
+
or self._log_task_exception(t)
|
138
|
+
)
|
139
|
+
except Exception:
|
140
|
+
self.logger.exception("Error in on_message_handler")
|
141
|
+
|
142
|
+
except websockets.exceptions.ConnectionClosed:
|
143
|
+
self.logger.info("WebSocket connection closed; exiting recv loop")
|
144
|
+
break
|
145
|
+
except Exception:
|
146
|
+
self.logger.exception("Error in recv_loop; backing off briefly")
|
147
|
+
await asyncio.sleep(0.5)
|
148
|
+
|
149
|
+
def _log_task_exception(self, task: asyncio.Task[Any]) -> None:
|
150
|
+
try:
|
151
|
+
exc = task.exception()
|
152
|
+
if exc:
|
153
|
+
self.logger.exception("Background task exception: %s", exc)
|
154
|
+
except Exception:
|
155
|
+
pass
|
156
|
+
|
157
|
+
@override
|
158
|
+
async def _send_and_wait(
|
159
|
+
self,
|
160
|
+
opcode: int,
|
161
|
+
payload: dict[str, Any],
|
162
|
+
cmd: int = 0,
|
163
|
+
timeout: float = Constants.DEFAULT_TIMEOUT.value,
|
164
|
+
) -> dict[str, Any]:
|
165
|
+
ws = self.ws
|
166
|
+
|
167
|
+
msg = self._make_message(opcode, payload, cmd)
|
168
|
+
loop = asyncio.get_running_loop()
|
169
|
+
fut: asyncio.Future[dict[str, Any]] = loop.create_future()
|
170
|
+
self._pending[msg["seq"]] = fut
|
171
|
+
|
172
|
+
try:
|
173
|
+
self.logger.debug(
|
174
|
+
"Sending frame opcode=%s cmd=%s seq=%s", opcode, cmd, msg["seq"]
|
175
|
+
)
|
176
|
+
await ws.send(json.dumps(msg))
|
177
|
+
data = await asyncio.wait_for(fut, timeout=timeout)
|
178
|
+
self.logger.debug(
|
179
|
+
"Received frame for seq=%s opcode=%s",
|
180
|
+
data.get("seq"),
|
181
|
+
data.get("opcode"),
|
182
|
+
)
|
183
|
+
return data
|
184
|
+
except Exception:
|
185
|
+
self.logger.exception(
|
186
|
+
"Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"]
|
187
|
+
)
|
188
|
+
raise RuntimeError("Send and wait failed")
|
189
|
+
finally:
|
190
|
+
self._pending.pop(msg["seq"], None)
|
191
|
+
|
192
|
+
async def _sync(self) -> None:
|
193
|
+
self.logger.info("Starting initial sync")
|
194
|
+
|
195
|
+
payload = SyncPayload(
|
196
|
+
interactive=True,
|
197
|
+
token=self._token,
|
198
|
+
chats_sync=0,
|
199
|
+
contacts_sync=0,
|
200
|
+
presence_sync=0,
|
201
|
+
drafts_sync=0,
|
202
|
+
chats_count=40,
|
203
|
+
).model_dump(by_alias=True)
|
204
|
+
|
205
|
+
try:
|
206
|
+
data = await self._send_and_wait(opcode=19, payload=payload)
|
207
|
+
raw_payload = data.get("payload", {})
|
208
|
+
|
209
|
+
if error := raw_payload.get("error"):
|
210
|
+
self.logger.error("Sync error: %s", error)
|
211
|
+
|
212
|
+
if error == "login.token":
|
213
|
+
if self._ws:
|
214
|
+
await self._ws.close()
|
215
|
+
self.is_connected = False
|
216
|
+
self._ws = None
|
217
|
+
self._recv_task = None
|
218
|
+
raise LoginError(
|
219
|
+
raw_payload.get("localizedMessage", "Unknown error")
|
220
|
+
)
|
221
|
+
|
222
|
+
return
|
223
|
+
|
224
|
+
for raw_chat in raw_payload.get("chats", []):
|
225
|
+
try:
|
226
|
+
if raw_chat.get("type") == ChatType.DIALOG.value:
|
227
|
+
self.dialogs.append(Dialog.from_dict(raw_chat))
|
228
|
+
elif raw_chat.get("type") == ChatType.CHAT.value:
|
229
|
+
self.chats.append(Chat.from_dict(raw_chat))
|
230
|
+
elif raw_chat.get("type") == ChatType.CHANNEL.value:
|
231
|
+
self.channels.append(Channel.from_dict(raw_chat))
|
232
|
+
except Exception:
|
233
|
+
self.logger.exception("Error parsing chat entry")
|
234
|
+
|
235
|
+
if raw_payload.get("profile", {}).get("contact"):
|
236
|
+
self.me = Me.from_dict(
|
237
|
+
raw_payload.get("profile", {}).get("contact", {})
|
238
|
+
)
|
239
|
+
|
240
|
+
self.logger.info(
|
241
|
+
"Sync completed: dialogs=%d chats=%d channels=%d",
|
242
|
+
len(self.dialogs),
|
243
|
+
len(self.chats),
|
244
|
+
len(self.channels),
|
245
|
+
)
|
246
|
+
|
247
|
+
except LoginError:
|
248
|
+
raise
|
249
|
+
except Exception:
|
250
|
+
self.logger.exception("Sync failed")
|
251
|
+
self.is_connected = False
|
252
|
+
if self._ws:
|
253
|
+
await self._ws.close()
|
254
|
+
self._ws = None
|
255
|
+
raise
|
256
|
+
|
257
|
+
@override
|
258
|
+
async def _get_chat(self, chat_id: int) -> Chat | None:
|
259
|
+
for chat in self.chats:
|
260
|
+
if chat.id == chat_id:
|
261
|
+
return chat
|
262
|
+
return None
|
pymax/models.py
CHANGED
@@ -1,8 +1,8 @@
|
|
1
|
-
from uuid import UUID, uuid4
|
2
|
-
|
3
|
-
from sqlmodel import Field, SQLModel
|
4
|
-
|
5
|
-
|
6
|
-
class Auth(SQLModel, table=True):
|
7
|
-
token: str | None = None
|
8
|
-
device_id: UUID = Field(default_factory=uuid4, primary_key=True)
|
1
|
+
from uuid import UUID, uuid4
|
2
|
+
|
3
|
+
from sqlmodel import Field, SQLModel
|
4
|
+
|
5
|
+
|
6
|
+
class Auth(SQLModel, table=True):
|
7
|
+
token: str | None = None
|
8
|
+
device_id: UUID = Field(default_factory=uuid4, primary_key=True)
|