maxapi-python 0.1.3__py3-none-any.whl → 1.1.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.
pymax/mixins/websocket.py CHANGED
@@ -1,243 +1,262 @@
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 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
- try:
194
- self.logger.info("Starting initial sync")
195
-
196
- payload = SyncPayload(
197
- interactive=True,
198
- token=self._token,
199
- chats_sync=0,
200
- contacts_sync=0,
201
- presence_sync=0,
202
- drafts_sync=0,
203
- chats_count=40,
204
- ).model_dump(by_alias=True)
205
-
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
- return
212
-
213
- for raw_chat in raw_payload.get("chats", []):
214
- try:
215
- if raw_chat.get("type") == ChatType.DIALOG.value:
216
- self.dialogs.append(Dialog.from_dict(raw_chat))
217
- elif raw_chat.get("type") == ChatType.CHAT.value:
218
- self.chats.append(Chat.from_dict(raw_chat))
219
- elif raw_chat.get("type") == ChatType.CHANNEL.value:
220
- self.channels.append(Channel.from_dict(raw_chat))
221
- except Exception:
222
- self.logger.exception("Error parsing chat entry")
223
-
224
- if raw_payload.get("profile", {}).get("contact"):
225
- self.me = Me.from_dict(
226
- raw_payload.get("profile", {}).get("contact", {})
227
- )
228
-
229
- self.logger.info(
230
- "Sync completed: dialogs=%d chats=%d channels=%d",
231
- len(self.dialogs),
232
- len(self.chats),
233
- len(self.channels),
234
- )
235
- except Exception:
236
- self.logger.exception("Sync failed")
237
-
238
- @override
239
- async def _get_chat(self, chat_id: int) -> Chat | None:
240
- for chat in self.chats:
241
- if chat.id == chat_id:
242
- return chat
243
- return None
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") # type: ignore[]
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)