maxapi-python 2.4.0__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 (101) hide show
  1. {maxapi_python-2.4.0.dist-info → maxapi_python-2.5.0.dist-info}/METADATA +22 -12
  2. maxapi_python-2.5.0.dist-info/RECORD +195 -0
  3. {maxapi_python-2.4.0.dist-info → maxapi_python-2.5.0.dist-info}/WHEEL +1 -1
  4. pymax/__init__.py +8 -2
  5. pymax/_data/apk_fingerprints.json +374 -17
  6. pymax/_data/rootca_ssl_rsa2022.crt +33 -0
  7. pymax/api/auth/service.py +15 -2
  8. pymax/api/auth/types.py +3 -0
  9. pymax/api/bots/payloads.py +8 -0
  10. pymax/api/bots/service.py +13 -1
  11. pymax/api/chats/enums.py +1 -0
  12. pymax/api/chats/payloads.py +21 -1
  13. pymax/api/chats/service.py +41 -0
  14. pymax/api/facade.py +2 -0
  15. pymax/api/messages/__init__.py +1 -1
  16. pymax/api/messages/enums.py +1 -0
  17. pymax/api/messages/payloads.py +56 -4
  18. pymax/api/messages/service.py +405 -27
  19. pymax/api/response.py +1 -1
  20. pymax/api/stories/__init__.py +2 -0
  21. pymax/api/stories/enums.py +19 -0
  22. pymax/api/stories/payloads.py +64 -0
  23. pymax/api/stories/service.py +198 -0
  24. pymax/api/uploads/payloads.py +42 -1
  25. pymax/api/uploads/service.py +171 -69
  26. pymax/app.py +39 -8
  27. pymax/auth/__init__.py +2 -0
  28. pymax/auth/exceptions.py +9 -0
  29. pymax/auth/providers.py +6 -2
  30. pymax/auth/qr.py +11 -1
  31. pymax/auth/sms.py +11 -1
  32. pymax/base.py +146 -27
  33. pymax/client.py +39 -11
  34. pymax/client_web.py +15 -12
  35. pymax/config.py +101 -53
  36. pymax/di/__init__.py +2 -0
  37. pymax/di/context.py +18 -0
  38. pymax/di/resolver.py +38 -0
  39. pymax/dispatch/__init__.py +7 -0
  40. pymax/dispatch/dispatcher.py +143 -40
  41. pymax/dispatch/enums.py +1 -0
  42. pymax/dispatch/mapping.py +23 -3
  43. pymax/dispatch/middleware.py +32 -0
  44. pymax/dispatch/resolvers.py +4 -0
  45. pymax/dispatch/router.py +86 -34
  46. pymax/files/base.py +58 -1
  47. pymax/files/video.py +32 -40
  48. pymax/files/voice.py +18 -4
  49. pymax/filters/__init__.py +15 -0
  50. pymax/filters/base.py +218 -0
  51. pymax/filters/containers/__init__.py +2 -0
  52. pymax/filters/containers/chat.py +240 -0
  53. pymax/filters/containers/message.py +147 -0
  54. pymax/filters/filters.py +211 -0
  55. pymax/filters/magic.py +13 -0
  56. pymax/filters/presets.py +88 -0
  57. pymax/filters/refs.py +56 -0
  58. pymax/fingerprint/fingerprint.py +5 -22
  59. pymax/fingerprint/models.py +1 -0
  60. pymax/formatting/markdown.py +55 -1
  61. pymax/fsm/__init__.py +3 -0
  62. pymax/fsm/context.py +30 -0
  63. pymax/fsm/protocol.py +14 -0
  64. pymax/fsm/state.py +70 -0
  65. pymax/fsm/storage.py +151 -0
  66. pymax/infra/base.py +2 -0
  67. pymax/infra/bots.py +20 -0
  68. pymax/infra/chat.py +37 -5
  69. pymax/infra/message.py +204 -9
  70. pymax/infra/protocol.py +5 -1
  71. pymax/infra/stories.py +205 -0
  72. pymax/integrations/__init__.py +0 -0
  73. pymax/integrations/dishka.py +109 -0
  74. pymax/protocol/enums.py +2 -0
  75. pymax/protocol/tcp/payload.py +0 -2
  76. pymax/session/__init__.py +1 -1
  77. pymax/session/models.py +30 -0
  78. pymax/session/protocol.py +33 -7
  79. pymax/session/store.py +73 -5
  80. pymax/transport/tcp.py +11 -3
  81. pymax/types/domain/__init__.py +23 -1
  82. pymax/types/domain/attachments/__init__.py +1 -1
  83. pymax/types/domain/attachments/call.py +7 -1
  84. pymax/types/domain/attachments/contact.py +3 -3
  85. pymax/types/domain/attachments/control.py +3 -0
  86. pymax/types/domain/attachments/enums.py +16 -0
  87. pymax/types/domain/attachments/keyboards/__init__.py +1 -1
  88. pymax/types/domain/attachments/keyboards/enums.py +12 -0
  89. pymax/types/domain/attachments/keyboards/inline.py +74 -3
  90. pymax/types/domain/attachments/video.py +3 -3
  91. pymax/types/domain/callback.py +19 -0
  92. pymax/types/domain/comment.py +24 -0
  93. pymax/types/domain/enums.py +7 -0
  94. pymax/types/domain/message.py +158 -9
  95. pymax/types/domain/story.py +126 -0
  96. pymax/types/events/reaction.py +28 -4
  97. pymax/versions/__init__.py +2 -0
  98. pymax/versions/catalog.py +104 -0
  99. pymax/versions/exceptions.py +10 -0
  100. maxapi_python-2.4.0.dist-info/RECORD +0 -161
  101. {maxapi_python-2.4.0.dist-info → maxapi_python-2.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, TypeVar
4
+
5
+ from .base import ClientT, Filter, PathResolver
6
+
7
+ if TYPE_CHECKING:
8
+ pass
9
+
10
+ EventT = TypeVar("EventT")
11
+
12
+
13
+ class ContainsFilter(Filter[EventT, ClientT], PathResolver):
14
+ def __init__(
15
+ self,
16
+ path: list[str],
17
+ expected_type: type[EventT],
18
+ text: str,
19
+ ignore_case: bool,
20
+ ) -> None:
21
+ self.path = path
22
+ self.expected_type = expected_type
23
+ self.text = text
24
+ self.ignore_case = ignore_case
25
+
26
+ def __call__(self, event: EventT, _: ClientT) -> bool:
27
+ if not isinstance(event, self.expected_type):
28
+ return False
29
+
30
+ value = self.resolve_path(event)
31
+
32
+ if not isinstance(value, str):
33
+ return False
34
+
35
+ if self.ignore_case:
36
+ return self.text.casefold() in value.casefold()
37
+
38
+ return self.text in value
39
+
40
+
41
+ class StartsWithFilter(Filter[EventT, ClientT], PathResolver):
42
+ def __init__(
43
+ self,
44
+ path: list[str],
45
+ expected_type: type[EventT],
46
+ text: str,
47
+ ignore_case: bool,
48
+ ) -> None:
49
+ self.path = path
50
+ self.expected_type = expected_type
51
+ self.text = text
52
+ self.ignore_case = ignore_case
53
+
54
+ def __call__(self, event: EventT, _: ClientT) -> bool:
55
+ if not isinstance(event, self.expected_type):
56
+ return False
57
+
58
+ value = self.resolve_path(event)
59
+
60
+ if not isinstance(value, str):
61
+ return False
62
+
63
+ if self.ignore_case:
64
+ return value.casefold().startswith(self.text.casefold())
65
+
66
+ return value.startswith(self.text)
67
+
68
+
69
+ class EndsWithFilter(Filter[EventT, ClientT], PathResolver):
70
+ def __init__(
71
+ self,
72
+ path: list[str],
73
+ expected_type: type[EventT],
74
+ text: str,
75
+ ignore_case: bool,
76
+ ) -> None:
77
+ self.path = path
78
+ self.expected_type = expected_type
79
+ self.text = text
80
+ self.ignore_case = ignore_case
81
+
82
+ def __call__(self, event: EventT, _: ClientT) -> bool:
83
+ if not isinstance(event, self.expected_type):
84
+ return False
85
+
86
+ value = self.resolve_path(event)
87
+
88
+ if not isinstance(value, str):
89
+ return False
90
+
91
+ if self.ignore_case:
92
+ return value.casefold().endswith(self.text.casefold())
93
+
94
+ return value.endswith(self.text)
95
+
96
+
97
+ class LessThanFilter(Filter[EventT, ClientT], PathResolver):
98
+ def __init__(
99
+ self,
100
+ path: list[str],
101
+ expected_type: type[EventT],
102
+ expected: float,
103
+ ) -> None:
104
+ self.path = path
105
+ self.expected_type = expected_type
106
+ self.expected = expected
107
+
108
+ def __call__(self, event: EventT, _: ClientT) -> bool:
109
+ if not isinstance(event, self.expected_type):
110
+ return False
111
+
112
+ value = self.resolve_path(event)
113
+
114
+ if not isinstance(value, int | float) or isinstance(value, bool):
115
+ return False
116
+
117
+ return value < self.expected
118
+
119
+
120
+ class LessThanOrEqualFilter(Filter[EventT, ClientT], PathResolver):
121
+ def __init__(
122
+ self,
123
+ path: list[str],
124
+ expected_type: type[EventT],
125
+ expected: float,
126
+ ) -> None:
127
+ self.path = path
128
+ self.expected_type = expected_type
129
+ self.expected = expected
130
+
131
+ def __call__(self, event: EventT, _: ClientT) -> bool:
132
+ if not isinstance(event, self.expected_type):
133
+ return False
134
+
135
+ value = self.resolve_path(event)
136
+
137
+ if not isinstance(value, int | float) or isinstance(value, bool):
138
+ return False
139
+
140
+ return value <= self.expected
141
+
142
+
143
+ class GreaterThanFilter(Filter[EventT, ClientT], PathResolver):
144
+ def __init__(
145
+ self,
146
+ path: list[str],
147
+ expected_type: type[EventT],
148
+ expected: float,
149
+ ) -> None:
150
+ self.path = path
151
+ self.expected_type = expected_type
152
+ self.expected = expected
153
+
154
+ def __call__(self, event: EventT, _: ClientT) -> bool:
155
+ if not isinstance(event, self.expected_type):
156
+ return False
157
+
158
+ value = self.resolve_path(event)
159
+
160
+ if not isinstance(value, int | float) or isinstance(value, bool):
161
+ return False
162
+
163
+ return value > self.expected
164
+
165
+
166
+ class GreaterThanOrEqualFilter(Filter[EventT, ClientT], PathResolver):
167
+ def __init__(
168
+ self,
169
+ path: list[str],
170
+ expected_type: type[EventT],
171
+ expected: float,
172
+ ) -> None:
173
+ self.path = path
174
+ self.expected_type = expected_type
175
+ self.expected = expected
176
+
177
+ def __call__(self, event: EventT, _: ClientT) -> bool:
178
+ if not isinstance(event, self.expected_type):
179
+ return False
180
+
181
+ value = self.resolve_path(event)
182
+
183
+ if not isinstance(value, int | float) or isinstance(value, bool):
184
+ return False
185
+
186
+ return value >= self.expected
187
+
188
+
189
+ class BetweenFilter(Filter[EventT, ClientT], PathResolver):
190
+ def __init__(
191
+ self,
192
+ path: list[str],
193
+ expected_type: type[EventT],
194
+ minimum: float,
195
+ maximum: float,
196
+ ) -> None:
197
+ self.path = path
198
+ self.expected_type = expected_type
199
+ self.minimum = minimum
200
+ self.maximum = maximum
201
+
202
+ def __call__(self, event: EventT, _: ClientT) -> bool:
203
+ if not isinstance(event, self.expected_type):
204
+ return False
205
+
206
+ value = self.resolve_path(event)
207
+
208
+ if not isinstance(value, int | float) or isinstance(value, bool):
209
+ return False
210
+
211
+ return self.minimum <= value <= self.maximum
pymax/filters/magic.py ADDED
@@ -0,0 +1,13 @@
1
+ from pymax.types import Chat, Message
2
+
3
+ from .containers import ChatContainer, MessageContainer
4
+
5
+
6
+ class MagicFilter:
7
+ @property
8
+ def message(self) -> MessageContainer:
9
+ return MessageContainer(Message)
10
+
11
+ @property
12
+ def chat(self) -> ChatContainer:
13
+ return ChatContainer(Chat)
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from pymax.types import LinkType, Message
6
+
7
+ from .base import Filter
8
+
9
+ if TYPE_CHECKING:
10
+ from pymax.base import BaseClient
11
+ from pymax.types.domain.message import Attachment
12
+
13
+
14
+ class Command(Filter[Message, "BaseClient"]):
15
+ """Проверяет команду в первом слове текста сообщения.
16
+
17
+ Args:
18
+ command: Имя команды, например ``"start"`` для ``/start``.
19
+ prefix: Префикс команды или кортеж допустимых префиксов.
20
+
21
+ Аргументы после команды не влияют на совпадение.
22
+ """
23
+
24
+ def __init__(self, command: str, prefix: str | tuple[str, ...] = "/") -> None:
25
+ self.prefixes = (prefix,) if isinstance(prefix, str) else prefix
26
+
27
+ for prefix_ in self.prefixes:
28
+ self.command = command.removeprefix(prefix_)
29
+
30
+ def __call__(self, message: Message, _: BaseClient) -> bool:
31
+ if not message.text:
32
+ return False
33
+
34
+ parts = message.text.split(maxsplit=1)
35
+ if not parts:
36
+ return False
37
+
38
+ dirty_command = parts[0]
39
+
40
+ for prefix in self.prefixes:
41
+ if message.text.startswith(prefix) and dirty_command[len(prefix) :] == self.command:
42
+ return True
43
+
44
+ return False
45
+
46
+
47
+ class Reply(Filter[Message, "BaseClient"]):
48
+ """Проверяет, является ли сообщение ответом на другое сообщение."""
49
+
50
+ def __call__(self, message: Message, _: BaseClient) -> bool:
51
+ if not message.link:
52
+ return False
53
+
54
+ return message.link.type == LinkType.REPLY
55
+
56
+
57
+ class Forwarded(Filter[Message, "BaseClient"]):
58
+ """Проверяет, является ли сообщение пересланным."""
59
+
60
+ def __call__(self, message: Message, _: BaseClient) -> bool:
61
+ if not message.link:
62
+ return False
63
+
64
+ return message.link.type == LinkType.FORWARD
65
+
66
+
67
+ class FromMe(Filter[Message, "BaseClient"]):
68
+ """Проверяет, отправлено ли сообщение текущим пользователем клиента."""
69
+
70
+ def __call__(self, message: Message, client: BaseClient) -> bool:
71
+ if not message.sender or not client.me:
72
+ return False
73
+
74
+ return message.sender == client.me.contact.id
75
+
76
+
77
+ class HasAttachment(Filter[Message, "BaseClient"]):
78
+ """Проверяет наличие хотя бы одного вложения указанных типов.
79
+
80
+ Передавайте классы входящих вложений из ``pymax.types``, например
81
+ ``PhotoAttachment``, а не классы файлов для отправки.
82
+ """
83
+
84
+ def __init__(self, *attachment_types: type[Attachment]) -> None:
85
+ self.expected_types = attachment_types
86
+
87
+ def __call__(self, message: Message, _: BaseClient) -> bool:
88
+ return any(isinstance(attach, self.expected_types) for attach in message.attaches)
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)
@@ -1,47 +1,30 @@
1
1
  import hashlib
2
- import json
3
2
  import struct
4
- from importlib import resources
5
- from typing import Any
6
3
 
7
4
  from .models import ApkBuildFingerprint
8
5
 
9
6
 
10
7
  class FingerprintGenerator:
11
- def __init__(
12
- self,
13
- ) -> None:
14
- self.path = resources.files("pymax._data") / "apk_fingerprints.json"
15
- self.data = self.load_fingerprints()
16
-
17
- def load_fingerprints(self) -> Any:
18
- with self.path.open("r", encoding="utf-8") as f:
19
- return json.load(f)
8
+ def __init__(self, version_data: ApkBuildFingerprint) -> None:
9
+ self.data = version_data
20
10
 
21
11
  def generate_fingerprint(
22
12
  self,
23
- version: str,
24
13
  device_id: str,
25
14
  calls_seed: int,
26
15
  arch: str = "arm64-v8a",
27
16
  ) -> bytes | None:
28
- data = self.data.get(version)
29
- if not data:
30
- return None
31
-
32
- model = ApkBuildFingerprint.model_validate(data)
33
-
34
17
  seed_bytes = struct.pack(">q", calls_seed)
35
18
  device_bytes = device_id.encode("utf-8")
36
19
 
37
20
  h1 = hashlib.sha256(
38
- bytes.fromhex(model.certificate_meta_sha256) + seed_bytes + device_bytes
21
+ bytes.fromhex(self.data.certificate_meta_sha256) + seed_bytes + device_bytes
39
22
  ).digest()
40
23
  h2 = hashlib.sha256(
41
- bytes.fromhex(model.dex_meta_sha256) + seed_bytes + device_bytes
24
+ bytes.fromhex(self.data.dex_meta_sha256) + seed_bytes + device_bytes
42
25
  ).digest()
43
26
  h3 = hashlib.sha256(
44
- bytes.fromhex(model.so_meta_sha256[arch]) + seed_bytes + device_bytes
27
+ bytes.fromhex(self.data.so_meta_sha256[arch]) + seed_bytes + device_bytes
45
28
  ).digest()
46
29
 
47
30
  return h1 + h2 + h3
@@ -7,5 +7,6 @@ class ApkBuildFingerprint(BaseModel):
7
7
  certificate_meta_sha256: str
8
8
  certificate_sha256: list[str]
9
9
  dex_meta_sha256: str
10
+ so_meta_sha256_arm64_v8a: str
10
11
  so_meta_sha256: dict[str, str]
11
12
  build_number: int
@@ -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