maxapi-python 2.4.1__py3-none-any.whl → 2.5.0__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.
Files changed (74) hide show
  1. {maxapi_python-2.4.1.dist-info → maxapi_python-2.5.0.dist-info}/METADATA +17 -9
  2. {maxapi_python-2.4.1.dist-info → maxapi_python-2.5.0.dist-info}/RECORD +74 -45
  3. {maxapi_python-2.4.1.dist-info → maxapi_python-2.5.0.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +5 -1
  5. pymax/_data/apk_fingerprints.json +272 -0
  6. pymax/api/auth/service.py +1 -0
  7. pymax/api/auth/types.py +3 -0
  8. pymax/api/bots/payloads.py +8 -0
  9. pymax/api/bots/service.py +13 -1
  10. pymax/api/chats/enums.py +1 -0
  11. pymax/api/chats/payloads.py +19 -0
  12. pymax/api/chats/service.py +34 -0
  13. pymax/api/facade.py +2 -0
  14. pymax/api/messages/enums.py +1 -0
  15. pymax/api/messages/payloads.py +47 -1
  16. pymax/api/messages/service.py +308 -24
  17. pymax/api/response.py +1 -1
  18. pymax/api/stories/__init__.py +2 -0
  19. pymax/api/stories/enums.py +19 -0
  20. pymax/api/stories/payloads.py +64 -0
  21. pymax/api/stories/service.py +198 -0
  22. pymax/api/uploads/payloads.py +10 -1
  23. pymax/api/uploads/service.py +94 -7
  24. pymax/app.py +16 -4
  25. pymax/auth/qr.py +11 -1
  26. pymax/base.py +70 -25
  27. pymax/client.py +0 -1
  28. pymax/config.py +38 -32
  29. pymax/di/__init__.py +2 -0
  30. pymax/di/context.py +18 -0
  31. pymax/di/resolver.py +38 -0
  32. pymax/dispatch/__init__.py +7 -0
  33. pymax/dispatch/dispatcher.py +143 -40
  34. pymax/dispatch/enums.py +1 -0
  35. pymax/dispatch/mapping.py +23 -3
  36. pymax/dispatch/middleware.py +32 -0
  37. pymax/dispatch/resolvers.py +4 -0
  38. pymax/dispatch/router.py +86 -34
  39. pymax/filters/__init__.py +15 -0
  40. pymax/filters/base.py +218 -0
  41. pymax/filters/containers/__init__.py +2 -0
  42. pymax/filters/containers/chat.py +240 -0
  43. pymax/filters/containers/message.py +147 -0
  44. pymax/filters/filters.py +211 -0
  45. pymax/filters/magic.py +13 -0
  46. pymax/filters/presets.py +88 -0
  47. pymax/filters/refs.py +56 -0
  48. pymax/formatting/markdown.py +55 -1
  49. pymax/fsm/__init__.py +3 -0
  50. pymax/fsm/context.py +30 -0
  51. pymax/fsm/protocol.py +14 -0
  52. pymax/fsm/state.py +70 -0
  53. pymax/fsm/storage.py +151 -0
  54. pymax/infra/base.py +2 -0
  55. pymax/infra/bots.py +20 -0
  56. pymax/infra/chat.py +28 -0
  57. pymax/infra/message.py +195 -6
  58. pymax/infra/stories.py +205 -0
  59. pymax/integrations/__init__.py +0 -0
  60. pymax/integrations/dishka.py +109 -0
  61. pymax/protocol/enums.py +2 -0
  62. pymax/protocol/tcp/payload.py +0 -2
  63. pymax/types/domain/__init__.py +23 -1
  64. pymax/types/domain/attachments/__init__.py +1 -1
  65. pymax/types/domain/attachments/keyboards/__init__.py +1 -1
  66. pymax/types/domain/attachments/keyboards/enums.py +12 -0
  67. pymax/types/domain/attachments/keyboards/inline.py +74 -3
  68. pymax/types/domain/callback.py +19 -0
  69. pymax/types/domain/comment.py +24 -0
  70. pymax/types/domain/message.py +91 -22
  71. pymax/types/domain/story.py +126 -0
  72. pymax/types/events/reaction.py +28 -4
  73. pymax/versions/catalog.py +1 -1
  74. {maxapi_python-2.4.1.dist-info → maxapi_python-2.5.0.dist-info}/licenses/LICENSE +0 -0
pymax/filters/refs.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TypeVar
4
+
5
+ from .base import MISSING, ClientT, FieldRef
6
+ from .filters import (
7
+ BetweenFilter,
8
+ ContainsFilter,
9
+ EndsWithFilter,
10
+ GreaterThanFilter,
11
+ GreaterThanOrEqualFilter,
12
+ LessThanFilter,
13
+ LessThanOrEqualFilter,
14
+ StartsWithFilter,
15
+ )
16
+
17
+ EventT = TypeVar("EventT")
18
+
19
+
20
+ class TextFieldRef(FieldRef[EventT, ClientT]):
21
+ def contains(self, text: str, *, ignore_case: bool = False) -> ContainsFilter:
22
+ return ContainsFilter(self.path, self.expected_type, text, ignore_case)
23
+
24
+ def startswith(self, text: str, *, ignore_case: bool = False) -> StartsWithFilter:
25
+ return StartsWithFilter(self.path, self.expected_type, text, ignore_case)
26
+
27
+ def endswith(self, text: str, *, ignore_case: bool = False) -> EndsWithFilter:
28
+ return EndsWithFilter(self.path, self.expected_type, text, ignore_case)
29
+
30
+ def __call__(self, event: EventT, _: ClientT) -> bool:
31
+ if not isinstance(event, self.expected_type):
32
+ return False
33
+
34
+ value = self.resolve_path(event)
35
+
36
+ if value is MISSING:
37
+ return False
38
+
39
+ return bool(value)
40
+
41
+
42
+ class NumberFieldRef(FieldRef[EventT, ClientT]):
43
+ def __lt__(self, value: float) -> LessThanFilter:
44
+ return LessThanFilter(self.path, self.expected_type, value)
45
+
46
+ def __le__(self, value: float) -> LessThanOrEqualFilter:
47
+ return LessThanOrEqualFilter(self.path, self.expected_type, value)
48
+
49
+ def __gt__(self, value: float) -> GreaterThanFilter:
50
+ return GreaterThanFilter(self.path, self.expected_type, value)
51
+
52
+ def __ge__(self, value: float) -> GreaterThanOrEqualFilter:
53
+ return GreaterThanOrEqualFilter(self.path, self.expected_type, value)
54
+
55
+ def between(self, minimum: float, maximum: float) -> BetweenFilter:
56
+ return BetweenFilter(self.path, self.expected_type, minimum, maximum)
@@ -51,6 +51,29 @@ class Formatter:
51
51
 
52
52
  return label, url, url_end + 1
53
53
 
54
+ @staticmethod
55
+ def _is_intraword_single_marker(text: str, i: int, marker: str) -> bool:
56
+ if marker != "_":
57
+ return False
58
+ if i == 0 or i + 1 >= len(text):
59
+ return False
60
+ return text[i - 1].isalnum() and text[i + 1].isalnum()
61
+
62
+ @staticmethod
63
+ def _find_closing_marker(
64
+ text: str,
65
+ marker: str,
66
+ start: int,
67
+ end: int | None = None,
68
+ ) -> int:
69
+ while True:
70
+ closing_index = text.find(marker, start, len(text) if end is None else end)
71
+ if closing_index == -1:
72
+ return -1
73
+ if not Formatter._is_intraword_single_marker(text, closing_index, marker):
74
+ return closing_index
75
+ start = closing_index + len(marker)
76
+
54
77
  @staticmethod
55
78
  def format_markdown(text: str) -> tuple[str, list[Element]]:
56
79
  clean_text = ""
@@ -65,6 +88,34 @@ class Formatter:
65
88
  while i < len(text):
66
89
  handled = False
67
90
 
91
+ for marker in ("```", "`"):
92
+ if marker not in active:
93
+ continue
94
+
95
+ closing_index = text.find(marker, i)
96
+ content = text[i:closing_index]
97
+ clean_text += content
98
+ clean_pos += Formatter._code_units_len(content)
99
+
100
+ start = active.pop(marker)
101
+ length = clean_pos - start
102
+ if length > 0:
103
+ entities.append(
104
+ Element(
105
+ type=Formatter.MARKERS[marker],
106
+ from_=start,
107
+ length=length,
108
+ )
109
+ )
110
+
111
+ i = closing_index + len(marker)
112
+ handled = True
113
+ break
114
+
115
+ if handled:
116
+ line_start = False
117
+ continue
118
+
68
119
  # LINK: [text](url)
69
120
  parsed_link = Formatter._parse_link(text, i, clean_pos)
70
121
 
@@ -155,6 +206,8 @@ class Formatter:
155
206
  for marker in Formatter.MARKER_ORDER:
156
207
  if not text.startswith(marker, i):
157
208
  continue
209
+ if Formatter._is_intraword_single_marker(text, i, marker):
210
+ continue
158
211
 
159
212
  marker_len = len(marker)
160
213
 
@@ -180,7 +233,8 @@ class Formatter:
180
233
  break
181
234
 
182
235
  end = text.find("\n", i + marker_len)
183
- closing_index = text.find(
236
+ closing_index = Formatter._find_closing_marker(
237
+ text,
184
238
  marker,
185
239
  i + marker_len,
186
240
  None if end == -1 else end,
pymax/fsm/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .context import FSMContext
2
+ from .state import FsmGroup, State
3
+ from .storage import InMemoryFSM, InMemoryFSMStorage
pymax/fsm/context.py ADDED
@@ -0,0 +1,30 @@
1
+ from dataclasses import dataclass
2
+
3
+ from .protocol import FSMStorage
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class FSMKey:
8
+ client_id: int
9
+ user_id: int
10
+ chat_id: int
11
+
12
+
13
+ class FSMContext:
14
+ """Контекст состояния пользователя в текущем чате.
15
+
16
+ PyMax передает его в обязательный параметр обработчика сообщения
17
+ с аннотацией ``FSMContext``. Состояние и данные доступны через ``storage``.
18
+
19
+ Attributes:
20
+ storage: Хранилище состояния и данных текущего контекста.
21
+ key: Ключ контекста для текущих клиента, пользователя и чата.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ storage: FSMStorage,
27
+ key: FSMKey,
28
+ ) -> None:
29
+ self.storage = storage
30
+ self.key = key
pymax/fsm/protocol.py ADDED
@@ -0,0 +1,14 @@
1
+ from typing import Any, Protocol
2
+
3
+ from .state import State
4
+
5
+
6
+ class FSMStorage(Protocol):
7
+ async def get_state(self) -> str | None: ...
8
+ async def set_state(self, state: str | State | None, ttl: int | None = None) -> None: ...
9
+
10
+ async def get_data(self) -> dict[str, Any]: ...
11
+ async def set_data(self, key: str, value: Any) -> None: ...
12
+ async def update_data(self, **data: Any) -> None: ...
13
+
14
+ async def clear(self) -> None: ...
pymax/fsm/state.py ADDED
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from pymax.base import BaseClient
7
+
8
+ from pymax.filters import Filter
9
+ from pymax.types import Message
10
+
11
+
12
+ class State(Filter[Message, "BaseClient"]):
13
+ """Состояние FSM и фильтр сообщений по этому состоянию.
14
+
15
+ Объявляйте экземпляры атрибутами ``FsmGroup``. Один и тот же экземпляр
16
+ можно передать в ``ctx.storage.set_state()`` и ``router.on_message()``.
17
+ """
18
+
19
+ owner: type
20
+ name: str
21
+
22
+ def __set_name__(self, owner: type, name: str) -> None:
23
+ self.owner = owner
24
+ self.name = name
25
+
26
+ @property
27
+ def full_name(self) -> str:
28
+ """Возвращает полное имя состояния из имени модуля, группы и атрибута."""
29
+ return f"{self.owner.__module__}.{self.owner.__qualname__}:{self.name}"
30
+
31
+ def __str__(self) -> str:
32
+ return self.full_name
33
+
34
+ async def __call__(
35
+ self,
36
+ event: Message,
37
+ client: BaseClient,
38
+ ) -> bool:
39
+ if event.chat_id is None or event.sender is None:
40
+ return False
41
+
42
+ fsm_key = client._app.fsm_storage.resolve_key(client, event)
43
+
44
+ if not fsm_key:
45
+ return False
46
+
47
+ record = client._app.fsm_storage.records.get(fsm_key)
48
+
49
+ if record is None:
50
+ return False
51
+
52
+ return await record.get_state() == self.full_name
53
+
54
+
55
+ class FsmGroup:
56
+ """Группа состояний, объявленных атрибутами класса через ``State()``."""
57
+
58
+ __states__: dict[str, State] = {}
59
+
60
+ def __init_subclass__(cls) -> None:
61
+ super().__init_subclass__()
62
+
63
+ inherited: dict[str, State] = {}
64
+
65
+ for base in cls.__bases__:
66
+ inherited.update(getattr(base, "__states__", {}))
67
+
68
+ own = {name: value for name, value in vars(cls).items() if isinstance(value, State)}
69
+
70
+ cls.__states__ = inherited | own
pymax/fsm/storage.py ADDED
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from contextlib import asynccontextmanager
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import TYPE_CHECKING, Any, AsyncGenerator
8
+
9
+ from pymax.fsm.context import FSMContext, FSMKey
10
+ from pymax.types.domain.message import Message
11
+
12
+ from .protocol import FSMStorage
13
+ from .state import State
14
+
15
+ if TYPE_CHECKING:
16
+ from pymax.base import BaseClient
17
+ from pymax.dispatch import DispatchEvent
18
+
19
+
20
+ class InMemoryFSMStorage:
21
+ def __init__(self) -> None:
22
+ self.records: dict[FSMKey, InMemoryFSM] = {}
23
+ self.locks: dict[FSMKey, asyncio.Lock] = {}
24
+
25
+ def resolve_key(
26
+ self,
27
+ client: BaseClient,
28
+ event: DispatchEvent,
29
+ ) -> FSMKey | None:
30
+ if not isinstance(event, Message):
31
+ return None
32
+
33
+ if event.chat_id is None or event.sender is None:
34
+ return None
35
+
36
+ return FSMKey(
37
+ client_id=hash(client),
38
+ user_id=event.sender,
39
+ chat_id=event.chat_id,
40
+ )
41
+
42
+ def get_context(
43
+ self,
44
+ client: BaseClient,
45
+ event: Message,
46
+ ) -> FSMContext | None:
47
+ if not event.chat_id or not event.sender:
48
+ return None
49
+
50
+ key = self.resolve_key(client, event)
51
+
52
+ if not key:
53
+ return None
54
+
55
+ record = self.records.get(key)
56
+ if record is None:
57
+ record = InMemoryFSM()
58
+ self.records[key] = record
59
+
60
+ return FSMContext(storage=record, key=key)
61
+
62
+ @asynccontextmanager
63
+ async def isolate(
64
+ self,
65
+ client: BaseClient,
66
+ event: DispatchEvent,
67
+ ) -> AsyncGenerator[None]:
68
+ key = self.resolve_key(client, event)
69
+
70
+ if key is None:
71
+ yield
72
+ return
73
+
74
+ lock = self.locks.setdefault(key, asyncio.Lock())
75
+
76
+ async with lock:
77
+ yield
78
+
79
+
80
+ @dataclass
81
+ class FsmState:
82
+ value: str | None = None
83
+ created_at: datetime = datetime.now()
84
+ ttl: int | None = None
85
+
86
+
87
+ class InMemoryFSM(FSMStorage):
88
+ """Состояние и данные одного FSM-контекста в памяти процесса.
89
+
90
+ В обработчике это хранилище доступно как ``ctx.storage``.
91
+ """
92
+
93
+ def __init__(self) -> None:
94
+ self.state: FsmState = FsmState()
95
+ self.data: dict[str, Any] = {}
96
+ self.lock = asyncio.Lock()
97
+
98
+ async def get_state(self) -> str | None:
99
+ """Возвращает имя состояния или ``None``, если оно не задано или истек TTL.
100
+
101
+ Истечение TTL сбрасывает только состояние, сохраняя данные контекста.
102
+ """
103
+ async with self.lock:
104
+ if self.state.ttl is None:
105
+ return self.state.value
106
+
107
+ expires_at = self.state.created_at.timestamp() + self.state.ttl
108
+
109
+ if datetime.now().timestamp() >= expires_at:
110
+ self.state = FsmState()
111
+ return None
112
+
113
+ return self.state.value
114
+
115
+ async def set_state(self, state: str | State | None, ttl: int | None = None) -> None:
116
+ """Устанавливает состояние, сохраняя данные контекста.
117
+
118
+ Args:
119
+ state: Состояние, его строковое имя или ``None`` для сброса.
120
+ ttl: Срок действия в секундах с момента установки.
121
+ ``None`` означает отсутствие срока действия.
122
+ """
123
+ async with self.lock:
124
+ if state is None:
125
+ self.state = FsmState()
126
+ return
127
+
128
+ self.state.value = str(state)
129
+ self.state.ttl = ttl
130
+ self.state.created_at = datetime.now()
131
+
132
+ async def get_data(self) -> dict[str, Any]:
133
+ """Возвращает поверхностную копию словаря данных контекста."""
134
+ async with self.lock:
135
+ return self.data.copy()
136
+
137
+ async def set_data(self, key: str, value: Any) -> None:
138
+ """Записывает одно значение, сохраняя остальные ключи данных."""
139
+ async with self.lock:
140
+ self.data[key] = value
141
+
142
+ async def update_data(self, **data: Any) -> None:
143
+ """Добавляет или перезаписывает переданные ключи данных."""
144
+ async with self.lock:
145
+ self.data.update(data)
146
+
147
+ async def clear(self) -> None:
148
+ """Сбрасывает состояние и удаляет все данные текущего контекста."""
149
+ async with self.lock:
150
+ self.state = FsmState()
151
+ self.data.clear()
pymax/infra/base.py CHANGED
@@ -3,6 +3,7 @@ from .bots import BotsMixin
3
3
  from .chat import ChatMixin
4
4
  from .message import MessageMixin
5
5
  from .self import SelfMixin
6
+ from .stories import StoriesMixin
6
7
  from .user import UserMixin
7
8
 
8
9
 
@@ -13,5 +14,6 @@ class BaseMixin(
13
14
  MessageMixin,
14
15
  BotsMixin,
15
16
  AuthMixin,
17
+ StoriesMixin,
16
18
  ):
17
19
  """Собирает публичные API-методы клиента."""
pymax/infra/bots.py CHANGED
@@ -1,3 +1,4 @@
1
+ from pymax.types import ButtonType, CallbackResponse
1
2
  from pymax.types.domain import InitData
2
3
 
3
4
  from .protocol import IClientProtocol
@@ -31,3 +32,22 @@ class BotsMixin(IClientProtocol):
31
32
  chat_id=chat_id,
32
33
  start_param=start_param,
33
34
  )
35
+
36
+ async def send_callback(
37
+ self,
38
+ callback_id: str,
39
+ type: ButtonType,
40
+ payload: str | None = None,
41
+ ) -> CallbackResponse:
42
+ """Передает нажатие inline-кнопки бота.
43
+
44
+ Args:
45
+ callback_id: ID из ``InlineKeyboardAttachment.callback_id``.
46
+ type: Тип нажатой кнопки.
47
+ payload: Данные кнопки, если они есть.
48
+
49
+ Returns:
50
+ Результат нажатия. ``message`` и ``chat`` содержатся в нем,
51
+ только если Max вернул эти объекты.
52
+ """
53
+ return await self._app.api.bots.send_callback(callback_id, type, payload)
pymax/infra/chat.py CHANGED
@@ -414,3 +414,31 @@ class ChatMixin(IClientProtocol):
414
414
  permissions: Непустой список прав администратора.
415
415
  """
416
416
  return await self._app.api.chats.add_admin(chat_id, user_id, permissions)
417
+
418
+ async def set_channel_comments(self, chat_id: int, enabled: bool) -> None:
419
+ """Включает или выключает комментарии канала."""
420
+ return await self._app.api.chats.set_channel_comments(
421
+ chat_id=chat_id,
422
+ enabled=enabled,
423
+ )
424
+
425
+ async def block_comment_author(
426
+ self,
427
+ chat_id: int,
428
+ post_id: int,
429
+ user_ids: list[int],
430
+ message_id: int,
431
+ clean_msg_period: int = 0,
432
+ ) -> bool:
433
+ """Блокирует авторов в комментариях.
434
+
435
+ ``message_id`` — ID комментария для модерации.
436
+ ``clean_msg_period=0`` сохраняет комментарии, ``-1`` также удаляет их.
437
+ """
438
+ return await self._app.api.chats.block_comment_author(
439
+ chat_id=chat_id,
440
+ post_id=post_id,
441
+ user_ids=user_ids,
442
+ message_id=message_id,
443
+ clean_msg_period=clean_msg_period,
444
+ )