maxapi-python 0.1.1__py3-none-any.whl → 0.1.2__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/self.py ADDED
@@ -0,0 +1,38 @@
1
+ from pymax.interfaces import ClientProtocol
2
+ from pymax.payloads import ChangeProfilePayload
3
+ from pymax.static import Opcode
4
+
5
+
6
+ class SelfMixin(ClientProtocol):
7
+ async def change_profile(
8
+ self,
9
+ first_name: str,
10
+ last_name: str | None = None,
11
+ description: str | None = None,
12
+ ) -> bool:
13
+ """
14
+ Изменяет профиль
15
+
16
+ Args:
17
+ first_name (str): Имя.
18
+ last_name (str | None, optional): Фамилия. Defaults to None.
19
+ description (str | None, optional): Описание. Defaults to None.
20
+
21
+ Returns:
22
+ bool: True, если профиль изменен
23
+ """
24
+
25
+ payload = ChangeProfilePayload(
26
+ first_name=first_name,
27
+ last_name=last_name,
28
+ description=description,
29
+ ).model_dump(
30
+ by_alias=True,
31
+ exclude_none=True,
32
+ )
33
+
34
+ data = await self._send_and_wait(opcode=Opcode.PROFILE, payload=payload)
35
+ if error := data.get("payload", {}).get("error"):
36
+ self.logger.error("Change profile error: %s", error)
37
+ return False
38
+ return True
pymax/mixins/user.py ADDED
@@ -0,0 +1,82 @@
1
+ from pymax.interfaces import ClientProtocol
2
+ from pymax.payloads import FetchContactsPayload
3
+ from pymax.static import Opcode
4
+ from pymax.types import User
5
+
6
+
7
+ class UserMixin(ClientProtocol):
8
+ def get_cached_user(self, user_id: int) -> User | None:
9
+ """
10
+ Получает юзера из кеша по его ID
11
+
12
+ Args:
13
+ user_id (int): ID пользователя.
14
+
15
+ Returns:
16
+ User | None: Объект User или None при ошибке.
17
+ """
18
+ user = self._users.get(user_id)
19
+ self.logger.debug("get_cached_user id=%s hit=%s", user_id, bool(user))
20
+ return user
21
+
22
+ async def get_users(self, user_ids: list[int]) -> list[User]:
23
+ """
24
+ Получает информацию о пользователях по их ID (с кешем).
25
+ """
26
+ self.logger.debug("get_users ids=%s", user_ids)
27
+ cached = {uid: self._users[uid] for uid in user_ids if uid in self._users}
28
+ missing_ids = [uid for uid in user_ids if uid not in self._users]
29
+
30
+ if missing_ids:
31
+ self.logger.debug("Fetching missing users: %s", missing_ids)
32
+ fetched_users = await self.fetch_users(missing_ids)
33
+ if fetched_users:
34
+ for user in fetched_users:
35
+ self._users[user.id] = user
36
+ cached[user.id] = user
37
+
38
+ ordered = [cached[uid] for uid in user_ids if uid in cached]
39
+ self.logger.debug("get_users result_count=%d", len(ordered))
40
+ return ordered
41
+
42
+ async def get_user(self, user_id: int) -> User | None:
43
+ """
44
+ Получает информацию о пользователе по его ID (с кешем).
45
+ """
46
+ self.logger.debug("get_user id=%s", user_id)
47
+ if user_id in self._users:
48
+ return self._users[user_id]
49
+
50
+ users = await self.fetch_users([user_id])
51
+ if users:
52
+ self._users[user_id] = users[0]
53
+ return users[0]
54
+ return None
55
+
56
+ async def fetch_users(self, user_ids: list[int]) -> None | list[User]:
57
+ """
58
+ Получает информацию о пользователях по их ID.
59
+ """
60
+ try:
61
+ self.logger.info("Fetching users count=%d", len(user_ids))
62
+
63
+ payload = FetchContactsPayload(contact_ids=user_ids).model_dump(
64
+ by_alias=True
65
+ )
66
+
67
+ data = await self._send_and_wait(
68
+ opcode=Opcode.CONTACT_INFO, payload=payload
69
+ )
70
+ if error := data.get("payload", {}).get("error"):
71
+ self.logger.error("Fetch users error: %s", error)
72
+ return None
73
+
74
+ users = [User.from_dict(u) for u in data["payload"].get("contacts", [])]
75
+ for user in users:
76
+ self._users[user.id] = user
77
+
78
+ self.logger.debug("Fetched users: %d", len(users))
79
+ return users
80
+ except Exception:
81
+ self.logger.exception("Fetch users failed")
82
+ return []
@@ -0,0 +1,242 @@
1
+ import asyncio
2
+ import json
3
+ from typing import Any, override
4
+
5
+ import websockets
6
+
7
+ from pymax.exceptions import WebSocketNotConnectedError
8
+ from pymax.interfaces import ClientProtocol
9
+ from pymax.payloads import BaseWebSocketMessage, SyncPayload
10
+ from pymax.static import ChatType, Constants, Opcode
11
+ from pymax.types import Channel, Chat, Dialog, Me, Message
12
+
13
+
14
+ class WebSocketMixin(ClientProtocol):
15
+ @property
16
+ def ws(self) -> websockets.ClientConnection:
17
+ if self._ws is None or not self.is_connected:
18
+ self.logger.critical("WebSocket not connected when access attempted")
19
+ raise WebSocketNotConnectedError
20
+ return self._ws
21
+
22
+ def _make_message(
23
+ self, opcode: int, payload: dict[str, Any], cmd: int = 0
24
+ ) -> dict[str, Any]:
25
+ self._seq += 1
26
+
27
+ msg = BaseWebSocketMessage(
28
+ ver=11,
29
+ cmd=cmd,
30
+ seq=self._seq,
31
+ opcode=opcode,
32
+ payload=payload,
33
+ ).model_dump(by_alias=True)
34
+
35
+ self.logger.debug(
36
+ "make_message opcode=%s cmd=%s seq=%s", opcode, cmd, self._seq
37
+ )
38
+ return msg
39
+
40
+ async def _send_interactive_ping(self) -> None:
41
+ while self.is_connected:
42
+ try:
43
+ await self._send_and_wait(
44
+ opcode=1,
45
+ payload={"interactive": True},
46
+ cmd=0,
47
+ )
48
+ self.logger.debug("Interactive ping sent successfully")
49
+ except Exception:
50
+ self.logger.warning("Interactive ping failed", exc_info=True)
51
+ await asyncio.sleep(30)
52
+
53
+ async def _connect(self, user_agent: dict[str, Any]) -> dict[str, Any]:
54
+ try:
55
+ self.logger.info("Connecting to WebSocket %s", self.uri)
56
+ self._ws = await websockets.connect(self.uri, origin="https://web.max.ru")
57
+ self.is_connected = True
58
+ self._incoming = asyncio.Queue()
59
+ self._pending = {}
60
+ self._recv_task = asyncio.create_task(self._recv_loop())
61
+ self.logger.info("WebSocket connected, starting handshake")
62
+ return await self._handshake(user_agent)
63
+ except Exception as e:
64
+ self.logger.error("Failed to connect: %s", e, exc_info=True)
65
+ raise ConnectionError(f"Failed to connect: {e}")
66
+
67
+ async def _handshake(self, user_agent: dict[str, Any]) -> dict[str, Any]:
68
+ try:
69
+ self.logger.debug(
70
+ "Sending handshake with user_agent keys=%s", list(user_agent.keys())
71
+ )
72
+ resp = await self._send_and_wait(
73
+ opcode=Opcode.SESSION_INIT,
74
+ payload={"deviceId": str(self._device_id), "userAgent": user_agent},
75
+ )
76
+ self.logger.info("Handshake completed")
77
+ return resp
78
+ except Exception as e:
79
+ self.logger.error("Handshake failed: %s", e, exc_info=True)
80
+ raise ConnectionError(f"Handshake failed: {e}")
81
+
82
+ async def _recv_loop(self) -> None:
83
+ if self._ws is None:
84
+ self.logger.warning("Recv loop started without websocket instance")
85
+ return
86
+
87
+ self.logger.debug("Receive loop started")
88
+ while True:
89
+ try:
90
+ raw = await self._ws.recv()
91
+ try:
92
+ data = json.loads(raw)
93
+ except Exception:
94
+ self.logger.warning("JSON parse error", exc_info=True)
95
+ continue
96
+
97
+ seq = data.get("seq")
98
+ fut = self._pending.get(seq) if isinstance(seq, int) else None
99
+
100
+ if fut and not fut.done():
101
+ fut.set_result(data)
102
+ self.logger.debug("Matched response for pending seq=%s", seq)
103
+ else:
104
+ if self._incoming is not None:
105
+ try:
106
+ self._incoming.put_nowait(data)
107
+ except asyncio.QueueFull:
108
+ self.logger.warning(
109
+ "Incoming queue full; dropping message seq=%s",
110
+ data.get("seq"),
111
+ )
112
+
113
+ if (
114
+ data.get("opcode") == Opcode.NOTIF_MESSAGE
115
+ and self._on_message_handlers
116
+ ):
117
+ try:
118
+ for handler, filter in self._on_message_handlers:
119
+ payload = data.get("payload", {})
120
+ msg = Message.from_dict(payload.get("message"))
121
+ if msg:
122
+ if msg.status:
123
+ continue # TODO: заглушка! сделать отдельный хендлер
124
+ if filter:
125
+ if filter.match(msg):
126
+ result = handler(msg)
127
+ else:
128
+ continue
129
+ else:
130
+ result = handler(msg)
131
+ if asyncio.iscoroutine(result):
132
+ task = asyncio.create_task(result)
133
+ self._background_tasks.add(task)
134
+ task.add_done_callback(
135
+ lambda t: self._background_tasks.discard(t)
136
+ or self._log_task_exception(t)
137
+ )
138
+ except Exception:
139
+ self.logger.exception("Error in on_message_handler")
140
+
141
+ except websockets.exceptions.ConnectionClosed:
142
+ self.logger.info("WebSocket connection closed; exiting recv loop")
143
+ break
144
+ except Exception:
145
+ self.logger.exception("Error in recv_loop; backing off briefly")
146
+ await asyncio.sleep(0.5)
147
+
148
+ def _log_task_exception(self, task: asyncio.Task[Any]) -> None:
149
+ try:
150
+ exc = task.exception()
151
+ if exc:
152
+ self.logger.exception("Background task exception: %s", exc)
153
+ except Exception:
154
+ pass
155
+
156
+ @override
157
+ async def _send_and_wait(
158
+ self,
159
+ opcode: int,
160
+ payload: dict[str, Any],
161
+ cmd: int = 0,
162
+ timeout: float = Constants.DEFAULT_TIMEOUT.value,
163
+ ) -> dict[str, Any]:
164
+ ws = self.ws
165
+
166
+ msg = self._make_message(opcode, payload, cmd)
167
+ loop = asyncio.get_running_loop()
168
+ fut: asyncio.Future[dict[str, Any]] = loop.create_future()
169
+ self._pending[msg["seq"]] = fut
170
+
171
+ try:
172
+ self.logger.debug(
173
+ "Sending frame opcode=%s cmd=%s seq=%s", opcode, cmd, msg["seq"]
174
+ )
175
+ await ws.send(json.dumps(msg))
176
+ data = await asyncio.wait_for(fut, timeout=timeout)
177
+ self.logger.debug(
178
+ "Received frame for seq=%s opcode=%s",
179
+ data.get("seq"),
180
+ data.get("opcode"),
181
+ )
182
+ return data
183
+ except Exception:
184
+ self.logger.exception(
185
+ "Send and wait failed (opcode=%s, seq=%s)", opcode, msg["seq"]
186
+ )
187
+ raise RuntimeError("Send and wait failed")
188
+ finally:
189
+ self._pending.pop(msg["seq"], None)
190
+
191
+ async def _sync(self) -> None:
192
+ try:
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
+ data = await self._send_and_wait(opcode=19, payload=payload)
206
+ raw_payload = data.get("payload", {})
207
+
208
+ if error := raw_payload.get("error"):
209
+ self.logger.error("Sync error: %s", error)
210
+ return
211
+
212
+ for raw_chat in raw_payload.get("chats", []):
213
+ try:
214
+ if raw_chat.get("type") == ChatType.DIALOG.value:
215
+ self.dialogs.append(Dialog.from_dict(raw_chat))
216
+ elif raw_chat.get("type") == ChatType.CHAT.value:
217
+ self.chats.append(Chat.from_dict(raw_chat))
218
+ elif raw_chat.get("type") == ChatType.CHANNEL.value:
219
+ self.channels.append(Channel.from_dict(raw_chat))
220
+ except Exception:
221
+ self.logger.exception("Error parsing chat entry")
222
+
223
+ if raw_payload.get("profile", {}).get("contact"):
224
+ self.me = Me.from_dict(
225
+ raw_payload.get("profile", {}).get("contact", {})
226
+ )
227
+
228
+ self.logger.info(
229
+ "Sync completed: dialogs=%d chats=%d channels=%d",
230
+ len(self.dialogs),
231
+ len(self.chats),
232
+ len(self.channels),
233
+ )
234
+ except Exception:
235
+ self.logger.exception("Sync failed")
236
+
237
+ @override
238
+ async def _get_chat(self, chat_id: int) -> Chat | None:
239
+ for chat in self.chats:
240
+ if chat.id == chat_id:
241
+ return chat
242
+ return None
pymax/models.py ADDED
@@ -0,0 +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)
pymax/payloads.py ADDED
@@ -0,0 +1,175 @@
1
+ from typing import Any, Literal
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from pymax.static import AttachType, AuthType
6
+
7
+
8
+ def to_camel(string: str) -> str:
9
+ parts = string.split("_")
10
+ return parts[0] + "".join(word.capitalize() for word in parts[1:])
11
+
12
+
13
+ class CamelModel(BaseModel):
14
+ model_config = {
15
+ "alias_generator": to_camel,
16
+ "populate_by_name": True,
17
+ }
18
+
19
+
20
+ class BaseWebSocketMessage(BaseModel):
21
+ ver: int = 11
22
+ cmd: int
23
+ seq: int
24
+ opcode: int
25
+ payload: dict[str, Any]
26
+
27
+
28
+ class RequestCodePayload(CamelModel):
29
+ phone: str
30
+ type: AuthType = AuthType.START_AUTH
31
+ language: str = "ru"
32
+
33
+
34
+ class SendCodePayload(CamelModel):
35
+ token: str
36
+ verify_code: str
37
+ auth_token_type: AuthType = AuthType.CHECK_CODE
38
+
39
+
40
+ class SyncPayload(CamelModel):
41
+ interactive: bool = True
42
+ token: str
43
+ chats_sync: int = 0
44
+ contacts_sync: int = 0
45
+ presence_sync: int = 0
46
+ drafts_sync: int = 0
47
+ chats_count: int = 40
48
+
49
+
50
+ class ReplyLink(CamelModel):
51
+ type: str = "REPLY"
52
+ message_id: str
53
+
54
+
55
+ class UploadPhotoPayload(CamelModel):
56
+ count: int = 1
57
+
58
+
59
+ class AttachPhotoPayload(CamelModel):
60
+ type: AttachType = Field(AttachType.PHOTO, alias="_type")
61
+ photo_token: str
62
+
63
+
64
+ class SendMessagePayloadMessage(CamelModel):
65
+ text: str
66
+ cid: int
67
+ elements: list[Any]
68
+ attaches: list[dict[str, Any]]
69
+ link: ReplyLink | None = None
70
+
71
+
72
+ class SendMessagePayload(CamelModel):
73
+ chat_id: int
74
+ message: SendMessagePayloadMessage
75
+ notify: bool = False
76
+
77
+
78
+ class EditMessagePayload(CamelModel):
79
+ chat_id: int
80
+ message_id: int
81
+ text: str
82
+ elements: list[Any]
83
+ attaches: list[Any]
84
+
85
+
86
+ class DeleteMessagePayload(CamelModel):
87
+ chat_id: int
88
+ message_ids: list[int]
89
+ for_me: bool = False
90
+
91
+
92
+ class FetchContactsPayload(CamelModel):
93
+ contact_ids: list[int]
94
+
95
+
96
+ class FetchHistoryPayload(CamelModel):
97
+ chat_id: int
98
+ from_time: int = Field(alias="from")
99
+ forward: int
100
+ backward: int = 200
101
+ get_messages: bool = True
102
+
103
+
104
+ class ChangeProfilePayload(CamelModel):
105
+ first_name: str
106
+ last_name: str | None = None
107
+ description: str | None = None
108
+
109
+
110
+ class ResolveLinkPayload(CamelModel):
111
+ link: str
112
+
113
+
114
+ class PinMessagePayload(CamelModel):
115
+ chat_id: int
116
+ notify_pin: bool
117
+ pin_message_id: int
118
+
119
+
120
+ class CreateGroupAttach(CamelModel):
121
+ type: Literal["CONTROL"] = Field("CONTROL", alias="_type")
122
+ event: str = "new"
123
+ chat_type: str = "CHAT"
124
+ title: str
125
+ user_ids: list[int]
126
+
127
+
128
+ class CreateGroupMessage(CamelModel):
129
+ cid: int
130
+ attaches: list[CreateGroupAttach]
131
+
132
+
133
+ class CreateGroupPayload(CamelModel):
134
+ message: CreateGroupMessage
135
+ notify: bool = True
136
+
137
+
138
+ class InviteUsersPayload(CamelModel):
139
+ chat_id: int
140
+ user_ids: list[int]
141
+ show_history: bool
142
+ operation: str = "add"
143
+
144
+
145
+ class RemoveUsersPayload(CamelModel):
146
+ chat_id: int
147
+ user_ids: list[int]
148
+ operation: str = "remove"
149
+ clean_msg_period: int
150
+
151
+
152
+ class ChangeGroupSettingsOptions(BaseModel):
153
+ ONLY_OWNER_CAN_CHANGE_ICON_TITLE: bool | None
154
+ ALL_CAN_PIN_MESSAGE: bool | None
155
+ ONLY_ADMIN_CAN_ADD_MEMBER: bool | None
156
+ ONLY_ADMIN_CAN_CALL: bool | None
157
+ MEMBERS_CAN_SEE_PRIVATE_LINK: bool | None
158
+
159
+
160
+ class ChangeGroupSettingsPayload(CamelModel):
161
+ chat_id: int
162
+ options: ChangeGroupSettingsOptions
163
+
164
+
165
+ class ChangeGroupProfilePayload(CamelModel):
166
+ chat_id: int
167
+ theme: str | None
168
+ description: str | None
169
+
170
+
171
+ class GetGroupMembersPayload(CamelModel):
172
+ type: str = "MEMBER"
173
+ marker: int
174
+ chat_id: int
175
+ count: int