maxapi-python 0.1.0__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/filters.py ADDED
@@ -0,0 +1,38 @@
1
+ from .static import MessageStatus, MessageType
2
+ from .types import Message
3
+
4
+
5
+ class Filter:
6
+ def __init__(
7
+ self,
8
+ user_id: int | None = None,
9
+ text: list[str] | None = None,
10
+ status: MessageStatus | str | None = None,
11
+ type: MessageType | str | None = None,
12
+ text_contains: str | None = None,
13
+ reaction_info: bool | None = None,
14
+ ) -> None:
15
+ self.user_id = user_id
16
+ self.text = text
17
+ self.status = status
18
+ self.type = type
19
+ self.reaction_info = reaction_info
20
+ self.text_contains = text_contains
21
+
22
+ def match(self, message: Message) -> bool:
23
+ if self.user_id is not None and message.sender != self.user_id:
24
+ return False
25
+ if self.text is not None and any(
26
+ text not in message.text for text in self.text
27
+ ):
28
+ return False
29
+ if self.text_contains is not None and self.text_contains not in message.text:
30
+ return False
31
+ if self.status is not None and message.status != self.status:
32
+ return False
33
+ if self.type is not None and message.type != self.type:
34
+ return False
35
+ if self.reaction_info is not None and message.reactionInfo is None: # noqa: SIM103
36
+ return False
37
+
38
+ return True
pymax/interfaces.py ADDED
@@ -0,0 +1,67 @@
1
+ import asyncio
2
+ import logging
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Awaitable, Callable
5
+ from logging import Logger
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import websockets
10
+
11
+ from .filters import Filter
12
+ from .static import Constants
13
+ from .types import Channel, Chat, Dialog, Me, Message, User
14
+
15
+ if TYPE_CHECKING:
16
+ from uuid import UUID
17
+
18
+ from .crud import Database
19
+
20
+
21
+ class ClientProtocol(ABC):
22
+ def __init__(self, logger: Logger) -> None:
23
+ super().__init__()
24
+ self.logger = logger
25
+ self._users: dict[int, User] = {}
26
+ self.chats: list[Chat] = []
27
+ self.phone: str = ""
28
+ self._database: Database
29
+ self._device_id: UUID
30
+ self._on_message_handlers: list[
31
+ tuple[Callable[[Message], Any], Filter | None]
32
+ ] = []
33
+ self.uri: str
34
+ self.is_connected: bool = False
35
+ self.phone: str
36
+ self.chats: list[Chat] = []
37
+ self.dialogs: list[Dialog] = []
38
+ self.channels: list[Channel] = []
39
+ self.me: Me | None = None
40
+ self._users: dict[int, User] = {}
41
+ self._work_dir: str
42
+ self._database_path: Path
43
+ self._ws: websockets.ClientConnection | None = None
44
+ self._seq: int = 0
45
+ self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
46
+ self._recv_task: asyncio.Task[Any] | None = None
47
+ self._incoming: asyncio.Queue[dict[str, Any]] | None = None
48
+ self.user_agent = Constants.DEFAULT_USER_AGENT.value
49
+ self._on_message_handlers: list[
50
+ tuple[Callable[[Message], Any], Filter | None]
51
+ ] = []
52
+ self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
53
+ self._background_tasks: set[asyncio.Task[Any]] = set()
54
+
55
+ @abstractmethod
56
+ async def _send_and_wait(
57
+ self,
58
+ opcode: int,
59
+ payload: dict[str, Any],
60
+ cmd: int = 0,
61
+ timeout: float = Constants.DEFAULT_TIMEOUT.value,
62
+ ) -> dict[str, Any]:
63
+ pass
64
+
65
+ @abstractmethod
66
+ async def _get_chat(self, chat_id: int) -> Chat | None:
67
+ pass
@@ -0,0 +1,18 @@
1
+ from .auth import AuthMixin
2
+ from .channel import ChannelMixin
3
+ from .handler import HandlerMixin
4
+ from .message import MessageMixin
5
+ from .self import SelfMixin
6
+ from .user import UserMixin
7
+ from .websocket import WebSocketMixin
8
+
9
+
10
+ class ApiMixin(
11
+ AuthMixin,
12
+ HandlerMixin,
13
+ UserMixin,
14
+ ChannelMixin,
15
+ SelfMixin,
16
+ MessageMixin,
17
+ ):
18
+ pass
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