maxapi-python 1.1.20__py3-none-any.whl → 1.2.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/filters.py CHANGED
@@ -1,47 +1,164 @@
1
- from .static.enum import MessageStatus, MessageType
2
- from .types import Message
3
-
4
-
5
- class Filter:
6
- def __init__(
7
- self,
8
- chat_id: int | None = None,
9
- user_id: int | None = None,
10
- text: list[str] | None = None,
11
- status: MessageStatus | str | None = None,
12
- type: MessageType | str | None = None,
13
- text_contains: str | None = None,
14
- reaction_info: bool | None = None,
15
- ) -> None:
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from abc import ABC, abstractmethod
5
+ from typing import Generic, TypeVar
6
+
7
+ from pymax.static.enum import AttachType, ChatType, MessageStatus
8
+ from pymax.types import Message
9
+
10
+ T_co = TypeVar("T_co")
11
+
12
+
13
+ class BaseFilter(ABC, Generic[T_co]):
14
+ event_type: type[T_co]
15
+
16
+ @abstractmethod
17
+ def __call__(self, event: T_co) -> bool: ...
18
+
19
+ def __and__(self, other: BaseFilter[T_co]) -> BaseFilter[T_co]:
20
+ return AndFilter(self, other)
21
+
22
+ def __or__(self, other: BaseFilter[T_co]) -> BaseFilter[T_co]:
23
+ return OrFilter(self, other)
24
+
25
+ def __invert__(self) -> BaseFilter[T_co]:
26
+ return NotFilter(self)
27
+
28
+
29
+ class AndFilter(BaseFilter[T_co]):
30
+ def __init__(self, *filters: BaseFilter[T_co]) -> None:
31
+ self.filters = filters
32
+ self.event_type = filters[0].event_type
33
+
34
+ def __call__(self, event: T_co) -> bool:
35
+ return all(f(event) for f in self.filters)
36
+
37
+
38
+ class OrFilter(BaseFilter[T_co]):
39
+ def __init__(self, *filters: BaseFilter[T_co]) -> None:
40
+ self.filters = filters
41
+ self.event_type = filters[0].event_type
42
+
43
+ def __call__(self, event: T_co) -> bool:
44
+ return any(f(event) for f in self.filters)
45
+
46
+
47
+ class NotFilter(BaseFilter[T_co]):
48
+ def __init__(self, base_filter: BaseFilter[T_co]) -> None:
49
+ self.base_filter = base_filter
50
+ self.event_type = base_filter.event_type
51
+
52
+ def __call__(self, event: T_co) -> bool:
53
+ return not self.base_filter(event)
54
+
55
+
56
+ class ChatFilter(BaseFilter[Message]):
57
+ event_type = Message
58
+
59
+ def __init__(self, chat_id: int) -> None:
16
60
  self.chat_id = chat_id
17
- self.user_id = user_id
61
+
62
+ def __call__(self, message: Message) -> bool:
63
+ return message.chat_id == self.chat_id
64
+
65
+
66
+ class TextFilter(BaseFilter[Message]):
67
+ event_type = Message
68
+
69
+ def __init__(self, text: str) -> None:
18
70
  self.text = text
71
+
72
+ def __call__(self, message: Message) -> bool:
73
+ return self.text in message.text
74
+
75
+
76
+ class SenderFilter(BaseFilter[Message]):
77
+ event_type = Message
78
+
79
+ def __init__(self, user_id: int) -> None:
80
+ self.user_id = user_id
81
+
82
+ def __call__(self, message: Message) -> bool:
83
+ return message.sender == self.user_id
84
+
85
+
86
+ class StatusFilter(BaseFilter[Message]):
87
+ event_type = Message
88
+
89
+ def __init__(self, status: MessageStatus) -> None:
19
90
  self.status = status
20
- self.type = type
21
- self.reaction_info = reaction_info
22
- self.text_contains = text_contains
23
91
 
24
- def match(self, message: Message) -> bool:
25
- if self.chat_id is not None and message.chat_id != self.chat_id:
26
- return False
27
- if self.user_id is not None and message.sender != self.user_id:
28
- return False
29
- if self.text is not None and any(
30
- text not in message.text for text in self.text
31
- ):
32
- return False
33
- if (
34
- self.text_contains is not None
35
- and self.text_contains not in message.text
36
- ):
37
- return False
38
- if self.status is not None and message.status != self.status:
39
- return False
40
- if self.type is not None and message.type != self.type:
41
- return False
42
- if (
43
- self.reaction_info is not None and message.reactionInfo is None
44
- ): # noqa: SIM103
92
+ def __call__(self, message: Message) -> bool:
93
+ return message.status == self.status
94
+
95
+
96
+ class TextContainsFilter(BaseFilter[Message]):
97
+ event_type = Message
98
+
99
+ def __init__(self, substring: str) -> None:
100
+ self.substring = substring
101
+
102
+ def __call__(self, message: Message) -> bool:
103
+ return self.substring in message.text
104
+
105
+
106
+ class RegexTextFilter(BaseFilter[Message]):
107
+ event_type = Message
108
+
109
+ def __init__(self, pattern: str) -> None:
110
+ self.pattern = pattern
111
+ self.regex = re.compile(pattern)
112
+
113
+ def __call__(self, message: Message) -> bool:
114
+ return bool(self.regex.search(message.text))
115
+
116
+
117
+ class MediaFilter(BaseFilter[Message]):
118
+ event_type = Message
119
+
120
+ def __call__(self, message: Message) -> bool:
121
+ return message.attaches is not None and len(message.attaches) > 0
122
+
123
+
124
+ class FileFilter(BaseFilter[Message]):
125
+ event_type = Message
126
+
127
+ def __call__(self, message: Message) -> bool:
128
+ if message.attaches is None:
45
129
  return False
130
+ return any(attach.type == AttachType.FILE for attach in message.attaches)
131
+
132
+
133
+ class Filters:
134
+ @staticmethod
135
+ def chat(chat_id: int) -> BaseFilter[Message]:
136
+ return ChatFilter(chat_id)
137
+
138
+ @staticmethod
139
+ def text(text: str) -> BaseFilter[Message]:
140
+ return TextFilter(text)
141
+
142
+ @staticmethod
143
+ def sender(user_id: int) -> BaseFilter[Message]:
144
+ return SenderFilter(user_id)
145
+
146
+ @staticmethod
147
+ def status(status: MessageStatus) -> BaseFilter[Message]:
148
+ return StatusFilter(status)
149
+
150
+ @staticmethod
151
+ def text_contains(substring: str) -> BaseFilter[Message]:
152
+ return TextContainsFilter(substring)
153
+
154
+ @staticmethod
155
+ def text_matches(pattern: str) -> BaseFilter[Message]:
156
+ return RegexTextFilter(pattern)
157
+
158
+ @staticmethod
159
+ def has_media() -> BaseFilter[Message]:
160
+ return MediaFilter()
46
161
 
47
- return True
162
+ @staticmethod
163
+ def has_file() -> BaseFilter[Message]:
164
+ return FileFilter()
pymax/formatter.py CHANGED
@@ -27,4 +27,5 @@ class ColoredFormatter(logging.Formatter):
27
27
  f"{name_color}{record.name}{self.RESET}: "
28
28
  f"{message_color}{record.getMessage()}{self.RESET}"
29
29
  )
30
+
30
31
  return log
pymax/formatting.py CHANGED
@@ -46,14 +46,12 @@ class Formatting:
46
46
 
47
47
  if inner_text is not None and fmt_type is not None:
48
48
  next_pos = match.end()
49
- has_newline = (
50
- next_pos < len(text) and text[next_pos] == "\n"
51
- ) or (next_pos == len(text))
49
+ has_newline = (next_pos < len(text) and text[next_pos] == "\n") or (
50
+ next_pos == len(text)
51
+ )
52
52
 
53
53
  length = len(inner_text) + (1 if has_newline else 0)
54
- elements.append(
55
- Element(type=fmt_type, from_=current_pos, length=length)
56
- )
54
+ elements.append(Element(type=fmt_type, from_=current_pos, length=length))
57
55
 
58
56
  clean_parts.append(inner_text)
59
57
  if has_newline:
pymax/interfaces.py CHANGED
@@ -1,11 +1,19 @@
1
1
  import asyncio
2
+ import contextlib
3
+ import logging
2
4
  import socket
3
5
  import ssl
6
+ import traceback
4
7
  from abc import ABC, abstractmethod
5
8
  from collections.abc import Awaitable, Callable
6
9
  from logging import Logger
7
10
  from typing import TYPE_CHECKING, Any, Literal
8
11
 
12
+ from typing_extensions import Self
13
+
14
+ from pymax.exceptions import WebSocketNotConnectedError
15
+ from pymax.formatter import ColoredFormatter
16
+
9
17
  from .payloads import UserAgentPayload
10
18
  from .static.constant import DEFAULT_TIMEOUT
11
19
  from .static.enum import Opcode
@@ -21,7 +29,7 @@ if TYPE_CHECKING:
21
29
  from pymax.types import ReactionInfo
22
30
 
23
31
  from .crud import Database
24
- from .filters import Filter
32
+ from .filters import BaseFilter
25
33
 
26
34
 
27
35
  class ClientProtocol(ABC):
@@ -67,18 +75,18 @@ class ClientProtocol(ABC):
67
75
  self._action_id: int = 0
68
76
  self._current_screen: str = "chats_list_tab"
69
77
  self._on_message_handlers: list[
70
- tuple[Callable[[Message], Any], Filter | None]
78
+ tuple[Callable[[Message], Any], BaseFilter[Message] | None]
71
79
  ] = []
72
80
  self._on_message_edit_handlers: list[
73
- tuple[Callable[[Message], Any], Filter | None]
81
+ tuple[Callable[[Message], Any], BaseFilter[Message] | None]
74
82
  ] = []
75
83
  self._on_message_delete_handlers: list[
76
- tuple[Callable[[Message], Any], Filter | None]
77
- ] = []
78
- self._on_reaction_change_handlers: list[
79
- tuple[Callable[[str, int, ReactionInfo], Any]]
84
+ tuple[Callable[[Message], Any], BaseFilter[Message] | None]
80
85
  ] = []
81
- self._on_chat_update_handlers: list[tuple[Callable[[Chat], Any]]] = []
86
+ self._on_reaction_change_handlers: list[Callable[[str, int, ReactionInfo], Any]] = []
87
+ self._on_chat_update_handlers: list[Callable[[Chat], Any | Awaitable[Any]]] = []
88
+ self._on_raw_receive_handlers: list[Callable[[dict[str, Any]], Any | Awaitable[Any]]] = []
89
+ self._scheduled_tasks: list[tuple[Callable[[], Any | Awaitable[Any]], float]] = []
82
90
  self._on_start_handler: Callable[[], Any | Awaitable[Any]] | None = None
83
91
  self._background_tasks: set[asyncio.Task[Any]] = set()
84
92
  self._ssl_context: ssl.SSLContext
@@ -114,3 +122,135 @@ class ClientProtocol(ABC):
114
122
  self, coro: Awaitable[Any], name: str | None = None
115
123
  ) -> asyncio.Task[Any]:
116
124
  pass
125
+
126
+
127
+ class BaseClient(ClientProtocol):
128
+ def _setup_logger(self) -> None:
129
+ if not self.logger.handlers:
130
+ if not self.logger.level:
131
+ self.logger.setLevel(logging.INFO)
132
+ handler = logging.StreamHandler()
133
+ formatter = ColoredFormatter(
134
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
135
+ datefmt="%Y-%m-%d %H:%M:%S",
136
+ )
137
+ handler.setFormatter(formatter)
138
+ self.logger.addHandler(handler)
139
+
140
+ async def _safe_execute(self, coro, *, context: str = "unknown") -> Any:
141
+ try:
142
+ return await coro
143
+ except Exception as e:
144
+ self.logger.error(f"Unhandled exception in {context}: {e}\n{traceback.format_exc()}")
145
+
146
+ def _create_safe_task(
147
+ self, coro: Awaitable[Any], name: str | None = None
148
+ ) -> asyncio.Task[Any | None]:
149
+ async def runner():
150
+ try:
151
+ return await coro
152
+ except asyncio.CancelledError:
153
+ raise
154
+ except Exception as e:
155
+ tb = traceback.format_exc()
156
+ self.logger.error(f"Unhandled exception in task {name or coro}: {e}\n{tb}")
157
+ raise
158
+
159
+ task = asyncio.create_task(runner(), name=name)
160
+ self._background_tasks.add(task)
161
+ return task
162
+
163
+ async def _cleanup_client(self) -> None:
164
+ for task in list(self._background_tasks):
165
+ task.cancel()
166
+ try:
167
+ await task
168
+ except asyncio.CancelledError:
169
+ pass
170
+ except Exception:
171
+ self.logger.debug("Background task raised during cancellation", exc_info=True)
172
+ self._background_tasks.discard(task)
173
+
174
+ if self._recv_task:
175
+ self._recv_task.cancel()
176
+ with contextlib.suppress(asyncio.CancelledError):
177
+ await self._recv_task
178
+ self._recv_task = None
179
+
180
+ if self._outgoing_task:
181
+ self._outgoing_task.cancel()
182
+ with contextlib.suppress(asyncio.CancelledError):
183
+ await self._outgoing_task
184
+ self._outgoing_task = None
185
+
186
+ for fut in self._pending.values():
187
+ if not fut.done():
188
+ fut.set_exception(WebSocketNotConnectedError())
189
+ self._pending.clear()
190
+
191
+ if self._ws:
192
+ try:
193
+ await self._ws.close()
194
+ except Exception:
195
+ self.logger.debug("Error closing ws during cleanup", exc_info=True)
196
+ self._ws = None
197
+
198
+ self.is_connected = False
199
+ self.logger.info("Client start() cleaned up")
200
+
201
+ async def idle(self):
202
+ """
203
+ Поддерживает клиента в «ожидающем» состоянии до закрытия клиента или иного прерывающего события.
204
+
205
+ :return: Никогда не возвращает значение; функция блокирует выполнение.
206
+ :rtype: None
207
+ """
208
+ await asyncio.Event().wait()
209
+
210
+ def inspect(self) -> None:
211
+ """
212
+ Выводит в лог текущий статус клиента для отладки.
213
+ """
214
+ self.logger.info("Pymax")
215
+ self.logger.info("---------")
216
+ self.logger.info(f"Connected: {self.is_connected}")
217
+ if self.me is not None:
218
+ self.logger.info(f"Me: {self.me.names[0].first_name} ({self.me.id})")
219
+ else:
220
+ self.logger.info("Me: N/A")
221
+ self.logger.info(f"Dialogs: {len(self.dialogs)}")
222
+ self.logger.info(f"Chats: {len(self.chats)}")
223
+ self.logger.info(f"Channels: {len(self.channels)}")
224
+ self.logger.info(f"Users cached: {len(self._users)}")
225
+ self.logger.info(f"Background tasks: {len(self._background_tasks)}")
226
+ self.logger.info(f"Scheduled tasks: {len(self._scheduled_tasks)}")
227
+ self.logger.info("---------")
228
+
229
+ async def __aenter__(self) -> Self:
230
+ self._create_safe_task(self.start(), name="start")
231
+ while not self.is_connected:
232
+ await asyncio.sleep(0.05)
233
+ return self
234
+
235
+ async def __aexit__(self, exc_type, exc, tb) -> None:
236
+ await self.close()
237
+
238
+ @abstractmethod
239
+ async def login_with_code(self, temp_token: str, code: str, start: bool = False) -> None:
240
+ pass
241
+
242
+ @abstractmethod
243
+ async def _post_login_tasks(self, sync: bool = True) -> None:
244
+ pass
245
+
246
+ @abstractmethod
247
+ async def _wait_forever(self) -> None:
248
+ pass
249
+
250
+ @abstractmethod
251
+ async def start(self) -> None:
252
+ pass
253
+
254
+ @abstractmethod
255
+ async def close(self) -> None:
256
+ pass
pymax/mixins/__init__.py CHANGED
@@ -3,6 +3,7 @@ from .channel import ChannelMixin
3
3
  from .group import GroupMixin
4
4
  from .handler import HandlerMixin
5
5
  from .message import MessageMixin
6
+ from .scheduler import SchedulerMixin
6
7
  from .self import SelfMixin
7
8
  from .socket import SocketMixin
8
9
  from .telemetry import TelemetryMixin
@@ -19,6 +20,7 @@ class ApiMixin(
19
20
  MessageMixin,
20
21
  TelemetryMixin,
21
22
  GroupMixin,
23
+ SchedulerMixin,
22
24
  ):
23
25
  pass
24
26
 
@@ -29,6 +31,7 @@ __all__ = [
29
31
  "ChannelMixin",
30
32
  "HandlerMixin",
31
33
  "MessageMixin",
34
+ "SchedulerMixin",
32
35
  "SelfMixin",
33
36
  "SocketMixin",
34
37
  "TelemetryMixin",