maxapi-python 0.1.1__py3-none-any.whl → 0.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.
pymax/mixins/auth.py ADDED
@@ -0,0 +1,81 @@
1
+ import asyncio
2
+ import re
3
+ from typing import Any
4
+
5
+ from pymax.interfaces import ClientProtocol
6
+ from pymax.payloads import RequestCodePayload, SendCodePayload
7
+ from pymax.static import AuthType, Constants, Opcode
8
+
9
+
10
+ class AuthMixin(ClientProtocol):
11
+ def _check_phone(self) -> bool:
12
+ return bool(re.match(Constants.PHONE_REGEX.value, self.phone))
13
+
14
+ async def _request_code(
15
+ self, phone: str, language: str = "ru"
16
+ ) -> dict[str, int | str]:
17
+ try:
18
+ self.logger.info("Requesting auth code")
19
+
20
+ payload = RequestCodePayload(
21
+ phone=phone, type=AuthType.START_AUTH, language=language
22
+ ).model_dump(by_alias=True)
23
+
24
+ data = await self._send_and_wait(
25
+ opcode=Opcode.AUTH_REQUEST, payload=payload
26
+ )
27
+ self.logger.debug(
28
+ "Code request response opcode=%s seq=%s",
29
+ data.get("opcode"),
30
+ data.get("seq"),
31
+ )
32
+ return data.get("payload")
33
+ except Exception:
34
+ self.logger.error("Request code failed", exc_info=True)
35
+ raise RuntimeError("Request code failed")
36
+
37
+ async def _send_code(self, code: str, token: str) -> dict[str, Any]:
38
+ try:
39
+ self.logger.info("Sending verification code")
40
+
41
+ payload = SendCodePayload(
42
+ token=token,
43
+ verify_code=code,
44
+ auth_token_type=AuthType.CHECK_CODE,
45
+ ).model_dump(by_alias=True)
46
+
47
+ data = await self._send_and_wait(opcode=Opcode.AUTH, payload=payload)
48
+ self.logger.debug(
49
+ "Send code response opcode=%s seq=%s",
50
+ data.get("opcode"),
51
+ data.get("seq"),
52
+ )
53
+ return data.get("payload")
54
+ except Exception:
55
+ self.logger.error("Send code failed", exc_info=True)
56
+ raise RuntimeError("Send code failed")
57
+
58
+ async def _login(self) -> None:
59
+ self.logger.info("Starting login flow")
60
+ request_code_payload = await self._request_code(self.phone)
61
+ temp_token = request_code_payload.get("token")
62
+ if not temp_token or not isinstance(temp_token, str):
63
+ self.logger.critical("Failed to request code: token missing")
64
+ raise ValueError("Failed to request code")
65
+
66
+ code = await asyncio.to_thread(input, "Введите код: ")
67
+ if len(code) != 6 or not code.isdigit():
68
+ self.logger.error("Invalid code format entered")
69
+ raise ValueError("Invalid code format")
70
+
71
+ login_resp = await self._send_code(code, temp_token)
72
+ token: str | None = (
73
+ login_resp.get("tokenAttrs", {}).get("LOGIN", {}).get("token")
74
+ )
75
+ if not token:
76
+ self.logger.critical("Failed to login, token not received")
77
+ raise ValueError("Failed to login, token not received")
78
+
79
+ self._token = token
80
+ self._database.update_auth_token(self._device_id, self._token)
81
+ self.logger.info("Login successful, token saved to database")
@@ -0,0 +1,25 @@
1
+ from pymax.interfaces import ClientProtocol
2
+ from pymax.payloads import ResolveLinkPayload
3
+ from pymax.static import Opcode
4
+
5
+
6
+ class ChannelMixin(ClientProtocol):
7
+ async def resolve_channel_by_name(self, name: str) -> bool:
8
+ """
9
+ Пытается найти канал по его имени
10
+
11
+ Args:
12
+ name (str): Имя канала
13
+
14
+ Returns:
15
+ bool: True, если канал найден
16
+ """
17
+ payload = ResolveLinkPayload(
18
+ link=f"https://max.ru/{name}",
19
+ ).model_dump(by_alias=True)
20
+
21
+ data = await self._send_and_wait(opcode=Opcode.LINK_INFO, payload=payload)
22
+ if error := data.get("payload", {}).get("error"):
23
+ self.logger.error("Resolve link error: %s", error)
24
+ return False
25
+ return True
pymax/mixins/group.py ADDED
@@ -0,0 +1,220 @@
1
+ import time
2
+
3
+ from pymax.interfaces import ClientProtocol
4
+ from pymax.payloads import (
5
+ ChangeGroupProfilePayload,
6
+ ChangeGroupSettingsOptions,
7
+ ChangeGroupSettingsPayload,
8
+ CreateGroupAttach,
9
+ CreateGroupMessage,
10
+ CreateGroupPayload,
11
+ InviteUsersPayload,
12
+ RemoveUsersPayload,
13
+ )
14
+ from pymax.static import Opcode
15
+ from pymax.types import Chat, Message
16
+
17
+
18
+ class GroupMixin(ClientProtocol):
19
+ async def create_group(
20
+ self, name: str, participant_ids: list[int] | None = None, notify: bool = True
21
+ ) -> tuple[Chat, Message] | None:
22
+ """
23
+ Создает группу
24
+
25
+ Args:
26
+ name (str): Название группы.
27
+ participant_ids (list[int] | None, optional): Список идентификаторов участников. Defaults to None.
28
+ notify (bool, optional): Флаг оповещения. Defaults to True.
29
+
30
+ Returns:
31
+ tuple[Chat, Message] | None: Объект Chat и Message или None при ошибке.
32
+ """
33
+ try:
34
+ payload = CreateGroupPayload(
35
+ message=CreateGroupMessage(
36
+ cid=int(time.time() * 1000),
37
+ attaches=[
38
+ CreateGroupAttach(
39
+ _type="CONTROL",
40
+ title=name,
41
+ user_ids=participant_ids if participant_ids else [],
42
+ )
43
+ ],
44
+ ),
45
+ notify=notify,
46
+ ).model_dump(by_alias=True)
47
+
48
+ data = await self._send_and_wait(opcode=Opcode.MSG_SEND, payload=payload)
49
+ if error := data.get("payload", {}).get("error"):
50
+ self.logger.error("Create group error: %s", error)
51
+ return None
52
+
53
+ chat = Chat.from_dict(data["payload"]["chat"])
54
+ message = Message.from_dict(data["payload"]["message"])
55
+
56
+ if chat:
57
+ cached_chat = await self._get_chat(chat.id)
58
+ if cached_chat is None:
59
+ self.chats.append(chat)
60
+ else:
61
+ idx = self.chats.index(cached_chat)
62
+ self.chats[idx] = chat
63
+
64
+ return chat, message
65
+
66
+ except Exception:
67
+ self.logger.exception("Create group failed")
68
+
69
+ async def invite_users_to_group(
70
+ self,
71
+ chat_id: int,
72
+ user_ids: list[int],
73
+ show_history: bool = True,
74
+ ) -> bool:
75
+ """
76
+ Приглашает пользователей в группу
77
+
78
+ Args:
79
+ chat_id (int): ID группы.
80
+ user_ids (list[int]): Список идентификаторов пользователей.
81
+ show_history (bool, optional): Флаг оповещения. Defaults to True.
82
+
83
+ Returns:
84
+ bool: True, если пользователи успешно приглашены
85
+ """
86
+ try:
87
+ payload = InviteUsersPayload(
88
+ chat_id=chat_id,
89
+ user_ids=user_ids,
90
+ show_history=show_history,
91
+ operation="add",
92
+ ).model_dump(by_alias=True)
93
+
94
+ data = await self._send_and_wait(
95
+ opcode=Opcode.CHAT_MEMBERS_UPDATE, payload=payload
96
+ )
97
+
98
+ if error := data.get("payload", {}).get("error"):
99
+ self.logger.error("Create group error: %s", error)
100
+ return False
101
+
102
+ chat = Chat.from_dict(data["payload"]["chat"])
103
+ if chat:
104
+ cached_chat = await self._get_chat(chat.id)
105
+ if cached_chat is None:
106
+ self.chats.append(chat)
107
+ else:
108
+ idx = self.chats.index(cached_chat)
109
+ self.chats[idx] = chat
110
+
111
+ return True
112
+
113
+ except Exception:
114
+ self.logger.exception("Invite users to group failed")
115
+ return False
116
+
117
+ async def remove_users_from_group(
118
+ self,
119
+ chat_id: int,
120
+ user_ids: list[int],
121
+ clean_msg_period: int,
122
+ ) -> bool:
123
+ try:
124
+ payload = RemoveUsersPayload(
125
+ chat_id=chat_id,
126
+ user_ids=user_ids,
127
+ clean_msg_period=clean_msg_period,
128
+ ).model_dump(by_alias=True)
129
+
130
+ data = await self._send_and_wait(
131
+ opcode=Opcode.CHAT_MEMBERS_UPDATE, payload=payload
132
+ )
133
+
134
+ if error := data.get("payload", {}).get("error"):
135
+ self.logger.error("Remove users from group error: %s", error)
136
+ return False
137
+
138
+ chat = Chat.from_dict(data["payload"]["chat"])
139
+ if chat:
140
+ cached_chat = await self._get_chat(chat.id)
141
+ if cached_chat is None:
142
+ self.chats.append(chat)
143
+ else:
144
+ idx = self.chats.index(cached_chat)
145
+ self.chats[idx] = chat
146
+
147
+ return True
148
+ except Exception:
149
+ self.logger.exception("Remove users from group failed")
150
+ return False
151
+
152
+ async def change_group_settings(
153
+ self,
154
+ chat_id: int,
155
+ all_can_pin_message: bool | None = None,
156
+ only_owner_can_change_icon_title: bool | None = None,
157
+ only_admin_can_add_member: bool | None = None,
158
+ only_admin_can_call: bool | None = None,
159
+ members_can_see_private_link: bool | None = None,
160
+ ):
161
+ try:
162
+ payload = ChangeGroupSettingsPayload(
163
+ chat_id=chat_id,
164
+ options=ChangeGroupSettingsOptions(
165
+ ALL_CAN_PIN_MESSAGE=all_can_pin_message,
166
+ ONLY_OWNER_CAN_CHANGE_ICON_TITLE=only_owner_can_change_icon_title,
167
+ ONLY_ADMIN_CAN_ADD_MEMBER=only_admin_can_add_member,
168
+ ONLY_ADMIN_CAN_CALL=only_admin_can_call,
169
+ MEMBERS_CAN_SEE_PRIVATE_LINK=members_can_see_private_link,
170
+ ),
171
+ ).model_dump(by_alias=True, exclude_none=True)
172
+
173
+ data = await self._send_and_wait(opcode=Opcode.CHAT_UPDATE, payload=payload)
174
+
175
+ if error := data.get("payload", {}).get("error"):
176
+ self.logger.error("Change group settings error: %s", error)
177
+ return
178
+
179
+ chat = Chat.from_dict(data["payload"]["chat"])
180
+ if chat:
181
+ cached_chat = await self._get_chat(chat.id)
182
+ if cached_chat is None:
183
+ self.chats.append(chat)
184
+ else:
185
+ idx = self.chats.index(cached_chat)
186
+ self.chats[idx] = chat
187
+
188
+ except Exception:
189
+ self.logger.exception("Change group settings failed")
190
+
191
+ async def change_group_profile(
192
+ self,
193
+ chat_id: int,
194
+ name: str | None,
195
+ description: str | None = None,
196
+ ):
197
+ try:
198
+ payload = ChangeGroupProfilePayload(
199
+ chat_id=chat_id,
200
+ theme=name,
201
+ description=description,
202
+ ).model_dump(by_alias=True, exclude_none=True)
203
+
204
+ data = await self._send_and_wait(opcode=Opcode.CHAT_UPDATE, payload=payload)
205
+
206
+ if error := data.get("payload", {}).get("error"):
207
+ self.logger.error("Change group profile error: %s", error)
208
+ return
209
+
210
+ chat = Chat.from_dict(data["payload"]["chat"])
211
+ if chat:
212
+ cached_chat = await self._get_chat(chat.id)
213
+ if cached_chat is None:
214
+ self.chats.append(chat)
215
+ else:
216
+ idx = self.chats.index(cached_chat)
217
+ self.chats[idx] = chat
218
+
219
+ except Exception:
220
+ self.logger.exception("Change group profile failed")
@@ -0,0 +1,60 @@
1
+ from typing import Any, Awaitable, Callable
2
+
3
+ from pymax.interfaces import ClientProtocol, Filter
4
+ from pymax.types import Message
5
+
6
+
7
+ class HandlerMixin(ClientProtocol):
8
+ def on_message(
9
+ self, *, filter: Filter | None = None
10
+ ) -> Callable[
11
+ [Callable[[Any], Any | Awaitable[Any]]], Callable[[Any], Any | Awaitable[Any]]
12
+ ]:
13
+ """
14
+ Декоратор для установки обработчика входящих сообщений.
15
+
16
+ Args:
17
+ filter: Фильтр для обработки сообщений.
18
+
19
+ Returns:
20
+ Декоратор.
21
+ """
22
+
23
+ def decorator(
24
+ handler: Callable[[Any], Any | Awaitable[Any]],
25
+ ) -> Callable[[Any], Any | Awaitable[Any]]:
26
+ self._on_message_handlers.append((handler, filter))
27
+ self.logger.info(f"on_message handler set: {handler}, filter: {filter}")
28
+ return handler
29
+
30
+ return decorator
31
+
32
+ def on_start(
33
+ self, handler: Callable[[], Any | Awaitable[Any]]
34
+ ) -> Callable[[], Any | Awaitable[Any]]:
35
+ """
36
+ Устанавливает обработчик, вызываемый при старте клиента.
37
+
38
+ Args:
39
+ handler: Функция или coroutine без аргументов.
40
+
41
+ Returns:
42
+ Установленный обработчик.
43
+ """
44
+ self._on_start_handler = handler
45
+ self.logger.debug("on_start handler set: %r", handler)
46
+ return handler
47
+
48
+ def add_message_handler(
49
+ self, handler: Callable[[Message], Any | Awaitable[Any]], filter: Filter | None
50
+ ) -> Callable[[Message], Any | Awaitable[Any]]:
51
+ self.logger.debug("add_message_handler (alias) used")
52
+ self._on_message_handlers.append((handler, filter))
53
+ return handler
54
+
55
+ def add_on_start_handler(
56
+ self, handler: Callable[[], Any | Awaitable[Any]]
57
+ ) -> Callable[[], Any | Awaitable[Any]]:
58
+ self.logger.debug("add_on_start_handler (alias) used")
59
+ self._on_start_handler = handler
60
+ return handler
@@ -0,0 +1,293 @@
1
+ import time
2
+
3
+ import aiohttp
4
+ from aiohttp import ClientSession
5
+
6
+ from pymax.files import File, Photo, Video
7
+ from pymax.interfaces import ClientProtocol
8
+ from pymax.payloads import (
9
+ AttachPhotoPayload,
10
+ DeleteMessagePayload,
11
+ EditMessagePayload,
12
+ FetchHistoryPayload,
13
+ PinMessagePayload,
14
+ ReplyLink,
15
+ SendMessagePayload,
16
+ SendMessagePayloadMessage,
17
+ UploadPhotoPayload,
18
+ )
19
+ from pymax.static import AttachType, Opcode
20
+ from pymax.types import Attach, Message
21
+
22
+
23
+ class MessageMixin(ClientProtocol):
24
+ async def _upload_photo(self, photo: Photo) -> None | Attach:
25
+ try:
26
+ self.logger.info("Uploading photo")
27
+ payload = UploadPhotoPayload().model_dump(by_alias=True)
28
+
29
+ data = await self._send_and_wait(
30
+ opcode=Opcode.PHOTO_UPLOAD,
31
+ payload=payload,
32
+ )
33
+ if error := data.get("payload", {}).get("error"):
34
+ self.logger.error("Upload photo error: %s", error)
35
+ return None
36
+
37
+ url = data.get("payload", {}).get("url")
38
+ if not url:
39
+ self.logger.error("No upload URL received")
40
+ return None
41
+
42
+ photo_data = photo.validate_photo()
43
+ if not photo_data:
44
+ self.logger.error("Photo validation failed")
45
+ return None
46
+
47
+ form = aiohttp.FormData()
48
+ form.add_field(
49
+ name="file",
50
+ value=await photo.read(),
51
+ filename=f"image.{photo_data[0]}",
52
+ content_type=photo_data[1],
53
+ )
54
+
55
+ async with (
56
+ ClientSession() as session,
57
+ session.post(
58
+ url=url,
59
+ data=form,
60
+ ) as response,
61
+ ):
62
+ if response.status != 200:
63
+ self.logger.error(f"Upload failed with status {response.status}")
64
+ return None
65
+
66
+ result = await response.json()
67
+
68
+ if not result.get("photos"):
69
+ self.logger.error("No photos in response")
70
+ return None
71
+
72
+ photo_data = next(iter(result["photos"].values()), None)
73
+ if not photo_data or "token" not in photo_data:
74
+ self.logger.error("No token in response")
75
+ return None
76
+
77
+ return Attach(
78
+ _type=AttachType.PHOTO,
79
+ photo_token=photo_data["token"],
80
+ )
81
+
82
+ except Exception as e:
83
+ self.logger.exception("Upload photo failed: %s", str(e))
84
+ return None
85
+
86
+ async def send_message(
87
+ self,
88
+ text: str,
89
+ chat_id: int,
90
+ notify: bool,
91
+ photo: Photo | None = None,
92
+ photos: list[Photo] | None = None,
93
+ reply_to: int | None = None,
94
+ ) -> Message | None:
95
+ """
96
+ Отправляет сообщение в чат.
97
+ """
98
+ try:
99
+ self.logger.info("Sending message to chat_id=%s notify=%s", chat_id, notify)
100
+ if photos and photo:
101
+ self.logger.warning("Both photo and photos provided; using photos")
102
+ photo = None
103
+ attaches = []
104
+ if photo:
105
+ self.logger.info("Uploading photo for message")
106
+ attach = await self._upload_photo(photo)
107
+ if not attach or not attach.photo_token:
108
+ self.logger.error("Photo upload failed, message not sent")
109
+ return None
110
+ attaches = [
111
+ AttachPhotoPayload(photo_token=attach.photo_token).model_dump(
112
+ by_alias=True
113
+ )
114
+ ]
115
+ elif photos:
116
+ self.logger.info("Uploading multiple photos for message")
117
+ for p in photos:
118
+ attach = await self._upload_photo(p)
119
+ if attach and attach.photo_token:
120
+ attaches.append(
121
+ AttachPhotoPayload(
122
+ photo_token=attach.photo_token
123
+ ).model_dump(by_alias=True)
124
+ )
125
+ if not attaches:
126
+ self.logger.error("All photo uploads failed, message not sent")
127
+ return None
128
+
129
+ payload = SendMessagePayload(
130
+ chat_id=chat_id,
131
+ message=SendMessagePayloadMessage(
132
+ text=text,
133
+ cid=int(time.time() * 1000),
134
+ elements=[],
135
+ attaches=attaches,
136
+ link=ReplyLink(message_id=str(reply_to)) if reply_to else None,
137
+ ),
138
+ notify=notify,
139
+ ).model_dump(by_alias=True)
140
+
141
+ data = await self._send_and_wait(opcode=Opcode.MSG_SEND, payload=payload)
142
+ if error := data.get("payload", {}).get("error"):
143
+ self.logger.error("Send message error: %s", error)
144
+ print(data)
145
+ return None
146
+ msg = (
147
+ Message.from_dict(data["payload"]["message"])
148
+ if data.get("payload")
149
+ else None
150
+ )
151
+ self.logger.debug("send_message result: %r", msg)
152
+ return msg
153
+ except Exception:
154
+ self.logger.exception("Send message failed")
155
+ return None
156
+
157
+ async def edit_message(
158
+ self, chat_id: int, message_id: int, text: str
159
+ ) -> Message | None:
160
+ """
161
+ Редактирует сообщение.
162
+ """
163
+ try:
164
+ self.logger.info(
165
+ "Editing message chat_id=%s message_id=%s", chat_id, message_id
166
+ )
167
+ payload = EditMessagePayload(
168
+ chat_id=chat_id,
169
+ message_id=message_id,
170
+ text=text,
171
+ elements=[],
172
+ attaches=[],
173
+ ).model_dump(by_alias=True)
174
+ data = await self._send_and_wait(opcode=Opcode.MSG_EDIT, payload=payload)
175
+ if error := data.get("payload", {}).get("error"):
176
+ self.logger.error("Edit message error: %s", error)
177
+ msg = (
178
+ Message.from_dict(data["payload"]["message"])
179
+ if data.get("payload")
180
+ else None
181
+ )
182
+ self.logger.debug("edit_message result: %r", msg)
183
+ return msg
184
+ except Exception:
185
+ self.logger.exception("Edit message failed")
186
+ return None
187
+
188
+ async def delete_message(
189
+ self, chat_id: int, message_ids: list[int], for_me: bool
190
+ ) -> bool:
191
+ """
192
+ Удаляет сообщения.
193
+ """
194
+ try:
195
+ self.logger.info(
196
+ "Deleting messages chat_id=%s ids=%s for_me=%s",
197
+ chat_id,
198
+ message_ids,
199
+ for_me,
200
+ )
201
+
202
+ payload = DeleteMessagePayload(
203
+ chat_id=chat_id, message_ids=message_ids, for_me=for_me
204
+ ).model_dump(by_alias=True)
205
+
206
+ data = await self._send_and_wait(opcode=Opcode.MSG_DELETE, payload=payload)
207
+ if error := data.get("payload", {}).get("error"):
208
+ self.logger.error("Delete message error: %s", error)
209
+ return False
210
+ self.logger.debug("delete_message success")
211
+ return True
212
+ except Exception:
213
+ self.logger.exception("Delete message failed")
214
+ return False
215
+
216
+ async def pin_message(
217
+ self, chat_id: int, message_id: int, notify_pin: bool
218
+ ) -> bool:
219
+ """
220
+ Закрепляет сообщение.
221
+
222
+ Args:
223
+ chat_id (int): ID чата
224
+ message_id (int): ID сообщения
225
+ notify_pin (bool): Оповещать о закреплении
226
+
227
+ Returns:
228
+ bool: True, если сообщение закреплено
229
+ """
230
+ try:
231
+ payload = PinMessagePayload(
232
+ chat_id=chat_id,
233
+ notify_pin=notify_pin,
234
+ pin_message_id=message_id,
235
+ ).model_dump(by_alias=True)
236
+
237
+ data = await self._send_and_wait(opcode=Opcode.CHAT_UPDATE, payload=payload)
238
+ if error := data.get("payload", {}).get("error"):
239
+ self.logger.error("Pin message error: %s", error)
240
+ return False
241
+ self.logger.debug("pin_message success")
242
+ return True
243
+ except Exception:
244
+ self.logger.exception("Pin message failed")
245
+ return False
246
+
247
+ async def fetch_history(
248
+ self,
249
+ chat_id: int,
250
+ from_time: int | None = None,
251
+ forward: int = 0,
252
+ backward: int = 200,
253
+ ) -> list[Message] | None:
254
+ """
255
+ Получает историю сообщений чата.
256
+ """
257
+ if from_time is None:
258
+ from_time = int(time.time() * 1000)
259
+
260
+ try:
261
+ self.logger.info(
262
+ "Fetching history chat_id=%s from=%s forward=%s backward=%s",
263
+ chat_id,
264
+ from_time,
265
+ forward,
266
+ backward,
267
+ )
268
+
269
+ payload = FetchHistoryPayload(
270
+ chat_id=chat_id,
271
+ from_time=from_time, # pyright: ignore[reportCallIssue] FIXME: Pydantic Field alias
272
+ forward=forward,
273
+ backward=backward,
274
+ ).model_dump(by_alias=True)
275
+
276
+ self.logger.debug("Payload dict keys: %s", list(payload.keys()))
277
+
278
+ data = await self._send_and_wait(
279
+ opcode=Opcode.CHAT_HISTORY, payload=payload, timeout=10
280
+ )
281
+
282
+ if error := data.get("payload", {}).get("error"):
283
+ self.logger.error("Fetch history error: %s", error)
284
+ return None
285
+
286
+ messages = [
287
+ Message.from_dict(msg) for msg in data["payload"].get("messages", [])
288
+ ]
289
+ self.logger.debug("History fetched: %d messages", len(messages))
290
+ return messages
291
+ except Exception:
292
+ self.logger.exception("Fetch history failed")
293
+ return None