python-trueconf-bot 1.3.0__py3-none-any.whl → 1.4.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.
- {python_trueconf_bot-1.3.0.dist-info → python_trueconf_bot-1.4.0.dist-info}/METADATA +1 -1
- {python_trueconf_bot-1.3.0.dist-info → python_trueconf_bot-1.4.0.dist-info}/RECORD +22 -10
- trueconf/__init__.py +2 -0
- trueconf/_version.py +2 -2
- trueconf/client/bot.py +16 -10
- trueconf/dispatcher/dispatcher.py +126 -40
- trueconf/dispatcher/router.py +156 -38
- trueconf/fsm/__init__.py +20 -0
- trueconf/fsm/context.py +57 -0
- trueconf/fsm/filters.py +31 -0
- trueconf/fsm/key_builder.py +39 -0
- trueconf/fsm/manager.py +36 -0
- trueconf/fsm/middleware.py +24 -0
- trueconf/fsm/state.py +135 -0
- trueconf/fsm/storage/__init__.py +4 -0
- trueconf/fsm/storage/base.py +36 -0
- trueconf/fsm/storage/memory.py +57 -0
- trueconf/fsm/strategy.py +36 -0
- trueconf/middleware.py +78 -0
- {python_trueconf_bot-1.3.0.dist-info → python_trueconf_bot-1.4.0.dist-info}/WHEEL +0 -0
- {python_trueconf_bot-1.3.0.dist-info → python_trueconf_bot-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {python_trueconf_bot-1.3.0.dist-info → python_trueconf_bot-1.4.0.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-trueconf-bot
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Lightweight and powerful framework for the TrueConf Server Chatbot API
|
|
5
5
|
Author-email: TrueConf LLC <info@trueconf.com>, Anton Baadzhi <baadzhianton@gmail.com>
|
|
6
6
|
License-Expression: BSD-3-Clause-Clear
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
python_trueconf_bot-1.
|
|
2
|
-
trueconf/__init__.py,sha256=
|
|
3
|
-
trueconf/_version.py,sha256=
|
|
1
|
+
python_trueconf_bot-1.4.0.dist-info/licenses/LICENSE,sha256=ftDuEzhr-yY_dsfpEQ2SKAP_dEAO-iPt96MBZq_K48o,1684
|
|
2
|
+
trueconf/__init__.py,sha256=Ka9VLhsdu-jZPndOQI1QpUXWldpu8xTC2BfgW51Px5U,481
|
|
3
|
+
trueconf/_version.py,sha256=u2oUj6dCZIqQ271Pm6INHvXKeiux4BSapl6g0c_tllg,520
|
|
4
4
|
trueconf/exceptions.py,sha256=JlnOybf6XBVKGYPPN3STlruupdfQXrLdeZ_FbtUuMIs,7653
|
|
5
5
|
trueconf/loggers.py,sha256=4dnq14qFo6fJ8SKZJCRZd0iJDYRqmcuX-ccv5FezxlQ,594
|
|
6
|
+
trueconf/middleware.py,sha256=7yaS4UjZSrPb-lv5STmX8qGg1OamYbO5Nd5-IjPtbU4,2281
|
|
6
7
|
trueconf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
8
|
trueconf/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
trueconf/client/bot.py,sha256=
|
|
9
|
+
trueconf/client/bot.py,sha256=vfIPb5ok5RUuVqvhoQzf5v0P_9Zz72sF_DiqJcl20wI,78735
|
|
9
10
|
trueconf/client/context_controller.py,sha256=_aItNHblyFbEqRsnbOM3ESgPqg2Pkji7xidTx0gb6zk,660
|
|
10
11
|
trueconf/client/session.py,sha256=CvHjxQXbkUyyS1r7YCFVL8al37zOYtnhw5ACNLJJDOc,1751
|
|
11
12
|
trueconf/dispatcher/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
-
trueconf/dispatcher/dispatcher.py,sha256=
|
|
13
|
-
trueconf/dispatcher/router.py,sha256=
|
|
13
|
+
trueconf/dispatcher/dispatcher.py,sha256=v2TRq90NZ5j85gJAANfVp1HPFB6Pt_AueFQCbmRCCt4,5802
|
|
14
|
+
trueconf/dispatcher/router.py,sha256=WFduzQCUVuCDtGA-4RxNcEipZt73y7_4n9iy11YO8KE,20642
|
|
14
15
|
trueconf/enums/__init__.py,sha256=YxjyW5UbyBAB8NWYqpxeSpuC6b_D7tghq0S4XWCySms,645
|
|
15
16
|
trueconf/enums/aouth_error.py,sha256=Ru5oZxam-2SnhSPZyapH79IlNrU_3t1l6RxsUJ4f5b8,462
|
|
16
17
|
trueconf/enums/chat_participant_role.py,sha256=2W-1nQdpvOWd8hDDgksPtykKdzewj6nwXWrvvHrQxGU,434
|
|
@@ -28,6 +29,17 @@ trueconf/filters/command.py,sha256=UWGn0yyFA5we2pRnL0BeUiJBjvN5slu_5GderrmQl8I,3
|
|
|
28
29
|
trueconf/filters/instance_of.py,sha256=O5s2AfoGvsXdsZLdk0OnAqUss-PeUQG7-giDpiJle6s,287
|
|
29
30
|
trueconf/filters/message.py,sha256=-4mSEokWo_Bqnk2K7ci3dI1niJw3X9CVFHo5xo1Qtis,480
|
|
30
31
|
trueconf/filters/method.py,sha256=v2cB3N8fzKWqz1AGJaUjsswen8d58pafOt4eB0qJtgM,367
|
|
32
|
+
trueconf/fsm/__init__.py,sha256=ewkpwMGTf0tFRXOFGrSCEZpyh5j0EXMNfbGZ5yHEK-w,554
|
|
33
|
+
trueconf/fsm/context.py,sha256=Eo__H5RJi7S3l1KQBggc1evg8dPFqkTZ0LSWfmy91x8,1726
|
|
34
|
+
trueconf/fsm/filters.py,sha256=v8RC_uSHL9N_xyu5_qSLbMN_aGIlk8W9dh_oYVk0Lps,939
|
|
35
|
+
trueconf/fsm/key_builder.py,sha256=7LVrpgWxS8_5uuCW_XSep72aZJ4cB-wsZCOSBe9bcTs,1228
|
|
36
|
+
trueconf/fsm/manager.py,sha256=ybcdv3id4lz4cCj85ZokpLmuZIJwJ70oRpntO0-6P20,1199
|
|
37
|
+
trueconf/fsm/middleware.py,sha256=F5MMVg5CbsFqpegr6_hpgvZ0xU8oxLQ0QM1FdPiAHkc,663
|
|
38
|
+
trueconf/fsm/state.py,sha256=MmF6QMekC-3alOBU0pByA7wZ_h3_SOuut_-X74-8pyc,4538
|
|
39
|
+
trueconf/fsm/strategy.py,sha256=m5--QhJf_NSq8rTN4xkxp2Zx76w5D8Jb8PIkGXvVjac,951
|
|
40
|
+
trueconf/fsm/storage/__init__.py,sha256=cDVH7QNbdIvUFDsEg0RIyxfvmYo_haALvqsBY8apFoM,148
|
|
41
|
+
trueconf/fsm/storage/base.py,sha256=f5vhdNt7mSXrXkqOTKCtj3CpJ24VqryWVKzLoOKuz5c,984
|
|
42
|
+
trueconf/fsm/storage/memory.py,sha256=Etib3uqMXfuD6hrsYZCxSp0awsM61c2NqjzWl5sFpmg,1781
|
|
31
43
|
trueconf/methods/__init__.py,sha256=fapNvuoZ16q6YUaJJjRWljoSXAOmyqq3PyUK8yyCh9Q,1911
|
|
32
44
|
trueconf/methods/add_participant_to_chat.py,sha256=uT-NBkHXFDfmHbi3f_hc3HJtS_jRS69yBuIQ87HczVQ,677
|
|
33
45
|
trueconf/methods/auth.py,sha256=ZEYoJpEKqUybDyqW8YJMPxA8QK2cAYXhipYr8V3nbj0,783
|
|
@@ -143,7 +155,7 @@ trueconf/utils/split_text.py,sha256=0HTVv63kLo2gx1r-ImM47ejTR4X_uDkNr5QMxvWdTBo,
|
|
|
143
155
|
trueconf/utils/formatting/__init__.py,sha256=Ii9rEamQdy8R0pVbmDf3xbdB4TtmgYyu2-RWRTCXukw,261
|
|
144
156
|
trueconf/utils/formatting/decorations.py,sha256=iQzCdFBELn16llCgTTPk43K5-BzY6BGPc1M3V2Q0tPQ,2175
|
|
145
157
|
trueconf/utils/formatting/text.py,sha256=pOW07TIV5vfvtWmGa2i2l_Q0UjFM6QMuowmsAjE9nyo,6867
|
|
146
|
-
python_trueconf_bot-1.
|
|
147
|
-
python_trueconf_bot-1.
|
|
148
|
-
python_trueconf_bot-1.
|
|
149
|
-
python_trueconf_bot-1.
|
|
158
|
+
python_trueconf_bot-1.4.0.dist-info/METADATA,sha256=Zp74Ghw3JmfBFYW7OPok0WFUAOi5GFLt64RmNDk76js,5700
|
|
159
|
+
python_trueconf_bot-1.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
160
|
+
python_trueconf_bot-1.4.0.dist-info/top_level.txt,sha256=GjgYdn_osTUuU-H8f59VhIS7MY44xLV7-D7veywJaeY,9
|
|
161
|
+
python_trueconf_bot-1.4.0.dist-info/RECORD,,
|
trueconf/__init__.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from trueconf.client.bot import Bot
|
|
2
2
|
from trueconf.dispatcher.dispatcher import Dispatcher
|
|
3
3
|
from trueconf.dispatcher.router import Router
|
|
4
|
+
from trueconf.middleware import BaseMiddleware, SkipSelfMessages
|
|
4
5
|
from magic_filter import F
|
|
5
6
|
from trueconf.types.message import Message
|
|
6
7
|
from trueconf.types import requests
|
|
@@ -11,6 +12,7 @@ __all__ = (
|
|
|
11
12
|
"Bot",
|
|
12
13
|
"Dispatcher",
|
|
13
14
|
"Router",
|
|
15
|
+
"BaseMiddleware",
|
|
14
16
|
"F",
|
|
15
17
|
"Message",
|
|
16
18
|
"requests",
|
trueconf/_version.py
CHANGED
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.
|
|
22
|
-
__version_tuple__ = version_tuple = (1,
|
|
21
|
+
__version__ = version = '1.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 4, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
trueconf/client/bot.py
CHANGED
|
@@ -125,6 +125,7 @@ class Bot:
|
|
|
125
125
|
dispatcher: Dispatcher | None = None,
|
|
126
126
|
receive_unread_messages: bool = False,
|
|
127
127
|
receive_system_messages: bool = False,
|
|
128
|
+
skip_self_messages: bool = True,
|
|
128
129
|
verify_ssl: SSLVerify = True,
|
|
129
130
|
web_port: int | None = None,
|
|
130
131
|
https: bool = True,
|
|
@@ -147,6 +148,8 @@ class Bot:
|
|
|
147
148
|
receive_unread_messages (bool, optional): Whether to receive unread messages on connection. Defaults to False.
|
|
148
149
|
receive_system_messages (bool, optional): Whether to receive system messages, such as user additions
|
|
149
150
|
to the chat or chat title changes. Defaults to False.
|
|
151
|
+
skip_self_messages (bool, optional): If True, the bot will ignore messages sent by itself.
|
|
152
|
+
This prevents echo loops when multiple bot sessions are running. Defaults to True.
|
|
150
153
|
verify_ssl (bool | str | ssl.SSLContext, optional): SSL verification mode. If True, verifies
|
|
151
154
|
the server certificate using the system trust store when available. If False, disables
|
|
152
155
|
certificate verification. If a string is provided, it must be a path to a CA bundle file.
|
|
@@ -200,10 +203,14 @@ class Bot:
|
|
|
200
203
|
self._me_id: str
|
|
201
204
|
self._on_health_check = on_health_check
|
|
202
205
|
|
|
206
|
+
if skip_self_messages:
|
|
207
|
+
from trueconf.middleware import SkipSelfMessages
|
|
208
|
+
self.dp.outer_middleware(SkipSelfMessages())
|
|
209
|
+
|
|
203
210
|
loggers.chatbot.info(
|
|
204
211
|
f"Bot initialized: server={server}:{self.port}, protocol={self._protocol}, "
|
|
205
212
|
f"verify_ssl={_describe_ssl_context(self.ssl_context)}, ws_max_retries={ws_max_retries}, ws_max_delay={ws_max_delay}, "
|
|
206
|
-
f"receive_unread={receive_unread_messages}"
|
|
213
|
+
f"receive_unread={receive_unread_messages}, skip_self={skip_self_messages}"
|
|
207
214
|
)
|
|
208
215
|
|
|
209
216
|
async def __call__(self, method: TrueConfMethod[T]) -> T:
|
|
@@ -322,6 +329,7 @@ class Bot:
|
|
|
322
329
|
dispatcher: Dispatcher | None = None,
|
|
323
330
|
receive_unread_messages: bool = False,
|
|
324
331
|
receive_system_messages: bool = False,
|
|
332
|
+
skip_self_messages: bool = True,
|
|
325
333
|
verify_ssl: SSLVerify = True,
|
|
326
334
|
web_port: int | None = None,
|
|
327
335
|
https: bool = True,
|
|
@@ -345,19 +353,16 @@ class Bot:
|
|
|
345
353
|
receive_unread_messages (bool, optional): Whether to receive unread messages on connection. Defaults to False.
|
|
346
354
|
receive_system_messages (bool, optional): Whether to receive system messages, such as user additions
|
|
347
355
|
to the chat or chat title changes. Defaults to False.
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
A custom ssl.SSLContext can also be passed. Defaults to True.
|
|
356
|
+
skip_self_messages (bool, optional): If True, the bot will ignore messages sent by itself.
|
|
357
|
+
Defaults to True.
|
|
358
|
+
verify_ssl (bool | str | ssl.SSLContext, optional): SSL verification mode. Defaults to True.
|
|
352
359
|
web_port (int, optional): WebSocket connection port. Defaults to 443.
|
|
353
360
|
https (bool, optional): Whether to use HTTPS protocol. Defaults to True.
|
|
354
361
|
ws_max_retries (int, optional): Max connection attempts on network/IP errors before giving up. Defaults to 5.
|
|
355
362
|
ws_max_delay (int, optional): Maximum delay between reconnection attempts (in seconds). Defaults to 60.
|
|
356
363
|
debug (bool, optional): Enables debug mode. Defaults to False.
|
|
357
364
|
on_health_check (HealthCheckCallback | None, optional): Async callback called when the bot
|
|
358
|
-
connection health changes.
|
|
359
|
-
WebSocket state, authorization state, server, port, protocol, timestamp, and optional
|
|
360
|
-
error details. Defaults to None.
|
|
365
|
+
connection health changes. Defaults to None.
|
|
361
366
|
|
|
362
367
|
Returns:
|
|
363
368
|
Bot: An authorized bot instance.
|
|
@@ -379,7 +384,8 @@ class Bot:
|
|
|
379
384
|
https=https,
|
|
380
385
|
dispatcher=dispatcher,
|
|
381
386
|
receive_unread_messages=receive_unread_messages,
|
|
382
|
-
receive_system_messages
|
|
387
|
+
receive_system_messages=receive_system_messages,
|
|
388
|
+
skip_self_messages=skip_self_messages,
|
|
383
389
|
verify_ssl=ssl_context,
|
|
384
390
|
ws_max_delay=ws_max_delay,
|
|
385
391
|
ws_max_retries=ws_max_retries,
|
|
@@ -758,7 +764,7 @@ class Bot:
|
|
|
758
764
|
if hasattr(payload, "bind"):
|
|
759
765
|
payload.bind(self)
|
|
760
766
|
|
|
761
|
-
await self.dp._feed_update(data)
|
|
767
|
+
await self.dp._feed_update(data, {"bot": self})
|
|
762
768
|
|
|
763
769
|
async def __on_raw_message(self, raw: str):
|
|
764
770
|
try:
|
|
@@ -1,63 +1,149 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from typing import List
|
|
2
|
+
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List
|
|
3
3
|
from trueconf.filters.base import Event
|
|
4
4
|
from trueconf.dispatcher.router import Router
|
|
5
5
|
|
|
6
|
+
MiddlewareHandler = Callable[[Event, Dict[str, Any]], Awaitable[None]]
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from trueconf.fsm.key_builder import KeyBuilder
|
|
10
|
+
from trueconf.fsm.manager import FSMManager
|
|
11
|
+
from trueconf.fsm.storage.base import BaseStorage
|
|
12
|
+
from trueconf.fsm.strategy import FSMStrategy
|
|
10
13
|
|
|
11
|
-
The `Dispatcher` aggregates one or more `Router` instances and feeds each
|
|
12
|
-
incoming event through them. The routers are traversed recursively via their
|
|
13
|
-
`subrouters` (using `_iter_all()`), and each event is passed to `_feed()` of
|
|
14
|
-
each router in order until it is handled.
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
class Dispatcher(Router):
|
|
16
|
+
"""Central dispatcher for routing incoming events.
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
The dispatcher is the root router of an application. It receives incoming
|
|
19
|
+
events, applies its own outer middleware chain, and then passes each event
|
|
20
|
+
to the included root routers in order. Processing stops when a router handles
|
|
21
|
+
the event, unless that router allows propagation to its child routers.
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
`Dispatcher` inherits from `Router`, so it supports the same handler,
|
|
24
|
+
middleware, and subrouter registration APIs.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Example:
|
|
27
|
+
```python
|
|
28
|
+
dispatcher = Dispatcher()
|
|
29
|
+
dispatcher.include_router(router)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
FSM example:
|
|
33
|
+
```python
|
|
34
|
+
from trueconf.fsm.storage.memory import MemoryStorage
|
|
35
|
+
|
|
36
|
+
storage = MemoryStorage()
|
|
37
|
+
dp = Dispatcher(storage=storage)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
storage: Storage backend used to create an FSM manager. Cannot be used
|
|
42
|
+
together with `fsm_manager`.
|
|
43
|
+
fsm_manager: Existing FSM manager instance. Cannot be used together
|
|
44
|
+
with `storage`.
|
|
45
|
+
key_builder: Key builder used when creating an FSM manager from
|
|
46
|
+
`storage`. Ignored when `fsm_manager` is passed.
|
|
47
|
+
strategy: FSM strategy used when creating an FSM manager from `storage`.
|
|
48
|
+
Ignored when `fsm_manager` is passed.
|
|
27
49
|
|
|
28
|
-
|
|
29
|
-
|
|
50
|
+
Attributes:
|
|
51
|
+
routers: Root routers included in the dispatcher.
|
|
52
|
+
fsm: FSM manager configured for the dispatcher, or `None` if FSM support
|
|
53
|
+
has not been enabled.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
storage: BaseStorage | None = None,
|
|
60
|
+
fsm_manager: FSMManager | None = None,
|
|
61
|
+
key_builder: KeyBuilder | None = None,
|
|
62
|
+
strategy: FSMStrategy | None = None,
|
|
63
|
+
):
|
|
64
|
+
super().__init__(name="dispatcher")
|
|
30
65
|
self.routers: List[Router] = []
|
|
66
|
+
self.fsm: FSMManager | None = None
|
|
31
67
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
68
|
+
if fsm_manager is not None and storage is not None:
|
|
69
|
+
raise ValueError("Pass either fsm_manager or storage, not both")
|
|
70
|
+
|
|
71
|
+
if fsm_manager is not None:
|
|
72
|
+
self.setup_fsm(fsm_manager=fsm_manager)
|
|
73
|
+
elif storage is not None:
|
|
74
|
+
self.setup_fsm(storage=storage, key_builder=key_builder, strategy=strategy)
|
|
35
75
|
|
|
36
|
-
|
|
37
|
-
|
|
76
|
+
def setup_fsm(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
fsm_manager: FSMManager | None = None,
|
|
80
|
+
storage: BaseStorage | None = None,
|
|
81
|
+
key_builder: KeyBuilder | None = None,
|
|
82
|
+
strategy: FSMStrategy | None = None,
|
|
83
|
+
) -> FSMManager:
|
|
84
|
+
from trueconf.fsm.key_builder import DefaultKeyBuilder
|
|
85
|
+
from trueconf.fsm.manager import FSMManager
|
|
86
|
+
from trueconf.fsm.middleware import FSMMiddleware
|
|
87
|
+
from trueconf.fsm.storage.memory import MemoryStorage
|
|
88
|
+
from trueconf.fsm.strategy import FSMStrategy
|
|
89
|
+
|
|
90
|
+
if self.fsm is not None:
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
"FSM is already configured for this Dispatcher. "
|
|
93
|
+
"Call setup_fsm() only once, or create a new Dispatcher."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if fsm_manager is None:
|
|
97
|
+
fsm_manager = FSMManager(
|
|
98
|
+
storage=storage or MemoryStorage(),
|
|
99
|
+
key_builder=key_builder or DefaultKeyBuilder(),
|
|
100
|
+
strategy=strategy or FSMStrategy.USER_IN_CHAT,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
self.fsm = fsm_manager
|
|
104
|
+
self._outer_middlewares.insert(0, FSMMiddleware(fsm_manager))
|
|
105
|
+
return fsm_manager
|
|
106
|
+
|
|
107
|
+
def include_router(self, router: "Router") -> None:
|
|
108
|
+
"""Include a root router in the dispatcher.
|
|
109
|
+
|
|
110
|
+
The dispatcher's own middleware is applied in ``_feed_update`` before
|
|
111
|
+
the event reaches child routers. Therefore we do NOT set ``_parent`` —
|
|
112
|
+
child routers should not inherit the dispatcher's middleware through
|
|
113
|
+
the ancestor chain.
|
|
38
114
|
"""
|
|
39
115
|
self.routers.append(router)
|
|
40
116
|
|
|
41
|
-
async def _feed_update(self, event: Event):
|
|
117
|
+
async def _feed_update(self, event: Event, data: Dict[str, Any]) -> None:
|
|
42
118
|
"""
|
|
43
|
-
Feeds an event to all routers
|
|
119
|
+
Feeds an event to all child routers in order,
|
|
44
120
|
stopping at the first one that handles it.
|
|
45
121
|
|
|
46
|
-
|
|
47
|
-
|
|
122
|
+
The event first passes through the dispatcher's own middleware chain
|
|
123
|
+
(outer middlewares from dispatcher ancestors → dispatcher), then is
|
|
124
|
+
fed to each child router.
|
|
48
125
|
|
|
49
|
-
|
|
50
|
-
|
|
126
|
+
Args:
|
|
127
|
+
event (Event): The event to be processed.
|
|
128
|
+
data (Dict[str, Any]): Context data passed through the middleware pipeline.
|
|
51
129
|
"""
|
|
52
130
|
|
|
53
|
-
async def
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
131
|
+
async def _feed_children(evt: Event, ctx: Dict[str, Any]) -> None:
|
|
132
|
+
async def progress_router(router: Router, count: int = 0) -> None:
|
|
133
|
+
handled = await router._feed(evt, ctx)
|
|
134
|
+
if count < 0 or count >= len(router._subrouters):
|
|
135
|
+
return
|
|
136
|
+
if (not handled) or (handled and router.allow_child_on_event):
|
|
137
|
+
subrouter = router._subrouters[count]
|
|
138
|
+
await progress_router(subrouter, count=len(router._subrouters) - 1)
|
|
139
|
+
|
|
140
|
+
for router in self.routers:
|
|
141
|
+
await progress_router(router)
|
|
142
|
+
|
|
143
|
+
# Build outer middleware chain: dispatcher outer_mw → feed_children
|
|
144
|
+
chain: MiddlewareHandler = _feed_children
|
|
145
|
+
for mw in reversed(self._collect_middlewares("_outer_middlewares")):
|
|
146
|
+
nxt = chain
|
|
147
|
+
chain = Router._wrap_middleware(mw, nxt)
|
|
148
|
+
|
|
149
|
+
await chain(event, data)
|
trueconf/dispatcher/router.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
import asyncio
|
|
3
3
|
import logging
|
|
4
4
|
import inspect
|
|
5
|
-
from typing import Callable, Awaitable, List, Tuple, Any, Union
|
|
5
|
+
from typing import TYPE_CHECKING, Callable, Awaitable, Dict, List, Tuple, Any, Union
|
|
6
6
|
from magic_filter import MagicFilter
|
|
7
7
|
from trueconf.filters.base import Event
|
|
8
8
|
from trueconf.filters.base import Filter
|
|
@@ -25,6 +25,9 @@ from trueconf.types.requests.removed_chat_participant import RemovedChatParticip
|
|
|
25
25
|
from trueconf.types.requests.removed_message import RemovedMessage
|
|
26
26
|
from trueconf.types.requests.uploading_progress import UploadingProgress
|
|
27
27
|
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from trueconf.middleware import BaseMiddleware
|
|
30
|
+
|
|
28
31
|
logger = logging.getLogger("chat_bot")
|
|
29
32
|
|
|
30
33
|
Handler = Callable[..., Awaitable[None]]
|
|
@@ -62,12 +65,19 @@ class Router:
|
|
|
62
65
|
If you have multiple routers, use `.include_router()` to add them to a parent router.
|
|
63
66
|
"""
|
|
64
67
|
|
|
65
|
-
def __init__(
|
|
66
|
-
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
name: str | None = None,
|
|
71
|
+
allow_child_on_event: bool = False,
|
|
72
|
+
_parent: "Router | None" = None,
|
|
73
|
+
):
|
|
67
74
|
self.name = name or hex(id(self))
|
|
68
75
|
self.allow_child_on_event = allow_child_on_event
|
|
76
|
+
self._parent: Router | None = _parent
|
|
69
77
|
self._handlers: List[Tuple[Tuple[FilterLike, ...], Handler]] = []
|
|
70
78
|
self._subrouters: List["Router"] = []
|
|
79
|
+
self._outer_middlewares: List["BaseMiddleware"] = []
|
|
80
|
+
self._inner_middlewares: List["BaseMiddleware"] = []
|
|
71
81
|
|
|
72
82
|
def _iter_all(self) -> List["Router"]:
|
|
73
83
|
"""Return a list of this router and all nested subrouters recursively."""
|
|
@@ -76,8 +86,42 @@ class Router:
|
|
|
76
86
|
out.extend(child._iter_all())
|
|
77
87
|
return out
|
|
78
88
|
|
|
89
|
+
def _ancestors_with_self(self) -> List["Router"]:
|
|
90
|
+
"""Return routers from root ancestor down to self."""
|
|
91
|
+
chain: list[Router] = []
|
|
92
|
+
current: Router | None = self
|
|
93
|
+
while current is not None:
|
|
94
|
+
chain.append(current)
|
|
95
|
+
current = current._parent
|
|
96
|
+
chain.reverse()
|
|
97
|
+
return chain
|
|
98
|
+
|
|
99
|
+
def _collect_middlewares(
|
|
100
|
+
self, attr: str
|
|
101
|
+
) -> List["BaseMiddleware"]:
|
|
102
|
+
"""Collect middlewares from ancestors → self."""
|
|
103
|
+
result: list[BaseMiddleware] = []
|
|
104
|
+
for router in self._ancestors_with_self():
|
|
105
|
+
result.extend(getattr(router, attr, []))
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
def outer_middleware(self, middleware: "BaseMiddleware") -> None:
|
|
109
|
+
"""Register outer middleware (runs before filter/handler matching)."""
|
|
110
|
+
self._outer_middlewares.append(middleware)
|
|
111
|
+
|
|
112
|
+
def inner_middleware(self, middleware: "BaseMiddleware") -> None:
|
|
113
|
+
"""Register inner middleware (runs after filter match, before handler)."""
|
|
114
|
+
self._inner_middlewares.append(middleware)
|
|
115
|
+
|
|
79
116
|
def _register(self, filters: Tuple[FilterLike, ...]):
|
|
80
117
|
"""Internal decorator for registering handlers with filters."""
|
|
118
|
+
# Sugar: State instances are auto-wrapped with StateFilter
|
|
119
|
+
from trueconf.fsm.filters import StateFilter
|
|
120
|
+
from trueconf.fsm.state import State
|
|
121
|
+
filters = tuple(
|
|
122
|
+
StateFilter(f) if isinstance(f, State) else f
|
|
123
|
+
for f in filters
|
|
124
|
+
)
|
|
81
125
|
|
|
82
126
|
def decorator(func: Handler):
|
|
83
127
|
async def async_wrapper(evt: Event, **kwargs: Any):
|
|
@@ -91,41 +135,96 @@ class Router:
|
|
|
91
135
|
|
|
92
136
|
return decorator
|
|
93
137
|
|
|
94
|
-
async def _feed(self, event: Event) -> bool:
|
|
95
|
-
"""Feed an incoming event to the router and invoke the first matching handler.
|
|
96
|
-
logger.info(f"📥 Incoming event: {event}")
|
|
97
|
-
for flts, handler in self._handlers:
|
|
138
|
+
async def _feed(self, event: Event, data: Dict[str, Any]) -> bool:
|
|
139
|
+
"""Feed an incoming event to the router and invoke the first matching handler.
|
|
98
140
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return True
|
|
141
|
+
Pipeline:
|
|
142
|
+
outer_middleware → filter match → inner_middleware → handler
|
|
102
143
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
except Exception as e:
|
|
110
|
-
logger.exception(f"Filter {type(f).__name__} error: {e}")
|
|
111
|
-
matched = False
|
|
112
|
-
break
|
|
144
|
+
Returns:
|
|
145
|
+
True — event was handled or blocked by outer middleware.
|
|
146
|
+
False — no handler matched and outer middleware did not block;
|
|
147
|
+
the dispatcher may try the next router.
|
|
148
|
+
"""
|
|
149
|
+
logger.info("📥 Incoming event: %s", event)
|
|
113
150
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
getattr(f, "__name__", type(f).__name__) if callable(f) else type(f).__name__
|
|
117
|
-
for f in flts
|
|
118
|
-
)
|
|
151
|
+
outer_passed = False
|
|
152
|
+
handler_found = False
|
|
119
153
|
|
|
154
|
+
async def _core(evt: Event, ctx: Dict[str, Any]) -> None:
|
|
155
|
+
nonlocal outer_passed, handler_found
|
|
156
|
+
outer_passed = True
|
|
157
|
+
|
|
158
|
+
# --- filter search ---
|
|
159
|
+
for flts, handler in self._handlers:
|
|
160
|
+
if not flts:
|
|
161
|
+
handler_found = True
|
|
162
|
+
self._spawn(handler, evt, "<none>")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
matched = True
|
|
120
166
|
kwargs: dict[str, Any] = {}
|
|
121
167
|
for f in flts:
|
|
122
|
-
|
|
168
|
+
try:
|
|
169
|
+
result = await self._apply_filter(f, evt, ctx)
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.exception("Filter %s error: %s", type(f).__name__, e)
|
|
172
|
+
matched = False
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
if not result:
|
|
176
|
+
matched = False
|
|
177
|
+
break
|
|
178
|
+
|
|
123
179
|
if isinstance(result, dict):
|
|
124
180
|
kwargs.update(result)
|
|
125
181
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
182
|
+
if matched:
|
|
183
|
+
handler_found = True
|
|
184
|
+
filters_str = ", ".join(
|
|
185
|
+
getattr(f, "__name__", type(f).__name__) if callable(f) else type(f).__name__
|
|
186
|
+
for f in flts
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Merge data dict (bot, state, etc.) with filter-returned kwargs
|
|
190
|
+
all_kwargs: dict[str, Any] = {**ctx, **kwargs}
|
|
191
|
+
|
|
192
|
+
# --- build inner chain: inner_mw → handler ---
|
|
193
|
+
async def _inner_base(ievt: Event, ictx: Dict[str, Any]) -> None:
|
|
194
|
+
self._spawn(handler, ievt, filters_str, **all_kwargs)
|
|
195
|
+
|
|
196
|
+
inner_chain: Callable[[Event, Dict[str, Any]], Awaitable[None]] = _inner_base
|
|
197
|
+
for mw in reversed(self._collect_middlewares("_inner_middlewares")):
|
|
198
|
+
nxt = inner_chain
|
|
199
|
+
inner_chain = self._wrap_middleware(mw, nxt)
|
|
200
|
+
|
|
201
|
+
await inner_chain(evt, ctx)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
# --- wrap with outer middlewares ---
|
|
205
|
+
chain: Callable[[Event, Dict[str, Any]], Awaitable[None]] = _core
|
|
206
|
+
for mw in reversed(self._collect_middlewares("_outer_middlewares")):
|
|
207
|
+
nxt = chain
|
|
208
|
+
chain = self._wrap_middleware(mw, nxt)
|
|
209
|
+
|
|
210
|
+
await chain(event, data)
|
|
211
|
+
|
|
212
|
+
if not outer_passed:
|
|
213
|
+
# Outer middleware blocked — event consumed
|
|
214
|
+
return True
|
|
215
|
+
if not handler_found:
|
|
216
|
+
# No handler matched — try next router
|
|
217
|
+
return False
|
|
218
|
+
return True
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _wrap_middleware(
|
|
222
|
+
mw: "BaseMiddleware",
|
|
223
|
+
nxt: Callable[[Event, Dict[str, Any]], Awaitable[None]],
|
|
224
|
+
) -> Callable[[Event, Dict[str, Any]], Awaitable[None]]:
|
|
225
|
+
async def wrapped(evt: Event, ctx: Dict[str, Any]) -> None:
|
|
226
|
+
await mw(nxt, evt, ctx)
|
|
227
|
+
return wrapped
|
|
129
228
|
|
|
130
229
|
def _spawn(self, handler: Handler, event: Event, filters_str: str, **kwargs: dict[str, Any]):
|
|
131
230
|
"""Internal method to spawn a task for executing the matched handler."""
|
|
@@ -140,24 +239,42 @@ class Router:
|
|
|
140
239
|
|
|
141
240
|
asyncio.create_task(_run())
|
|
142
241
|
|
|
143
|
-
async def _apply_filter(self, f: Filter | Any, event: Event) -> bool:
|
|
144
|
-
"""Evaluate a filter
|
|
242
|
+
async def _apply_filter(self, f: Filter | Any, event: Event, data: dict[str, Any] | None = None) -> bool:
|
|
243
|
+
"""Evaluate a filter against the event, passing matching kwargs from data."""
|
|
244
|
+
data = data or {}
|
|
245
|
+
|
|
145
246
|
if isinstance(f, MagicFilter):
|
|
146
247
|
try:
|
|
147
248
|
return bool(f.resolve(event))
|
|
148
249
|
except Exception:
|
|
149
250
|
return False
|
|
150
251
|
|
|
252
|
+
# Resolve which kwargs from data the filter accepts
|
|
253
|
+
kwargs: dict[str, Any] = {}
|
|
254
|
+
has_var_kwargs = False
|
|
151
255
|
try:
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
256
|
+
sig = inspect.signature(f)
|
|
257
|
+
for name, param in sig.parameters.items():
|
|
258
|
+
if param.kind == inspect.Parameter.VAR_KEYWORD:
|
|
259
|
+
has_var_kwargs = True
|
|
260
|
+
continue
|
|
261
|
+
if name in data and param.kind in (
|
|
262
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
263
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
264
|
+
):
|
|
265
|
+
kwargs[name] = data[name]
|
|
266
|
+
except (ValueError, TypeError):
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
# If filter accepts **kwargs, pass all remaining data
|
|
270
|
+
if has_var_kwargs:
|
|
271
|
+
kwargs.update({k: v for k, v in data.items() if k not in kwargs})
|
|
272
|
+
|
|
273
|
+
# Regular filters: let exceptions propagate (config errors must be explicit)
|
|
274
|
+
res = f(event, **kwargs) if kwargs else f(event)
|
|
155
275
|
|
|
156
276
|
if inspect.isawaitable(res):
|
|
157
|
-
|
|
158
|
-
res = await res
|
|
159
|
-
except Exception:
|
|
160
|
-
return False
|
|
277
|
+
res = await res
|
|
161
278
|
|
|
162
279
|
if isinstance(res, (bool, dict)):
|
|
163
280
|
return res
|
|
@@ -165,6 +282,7 @@ class Router:
|
|
|
165
282
|
|
|
166
283
|
def include_router(self, router: "Router") -> None:
|
|
167
284
|
"""Include a child router for hierarchical event routing."""
|
|
285
|
+
router._parent = self
|
|
168
286
|
self._subrouters.append(router)
|
|
169
287
|
|
|
170
288
|
def event(self, method: str, *filters: FilterLike):
|
trueconf/fsm/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from trueconf.fsm.context import FSMContext
|
|
2
|
+
from trueconf.fsm.filters import StateFilter
|
|
3
|
+
from trueconf.fsm.key_builder import DefaultKeyBuilder, KeyBuilder, StorageKey
|
|
4
|
+
from trueconf.fsm.manager import FSMManager
|
|
5
|
+
from trueconf.fsm.state import State, StatesGroup, any_state, default_state
|
|
6
|
+
from trueconf.fsm.strategy import FSMStrategy
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"FSMContext",
|
|
10
|
+
"FSMManager",
|
|
11
|
+
"FSMStrategy",
|
|
12
|
+
"State",
|
|
13
|
+
"StateFilter",
|
|
14
|
+
"StatesGroup",
|
|
15
|
+
"StorageKey",
|
|
16
|
+
"KeyBuilder",
|
|
17
|
+
"DefaultKeyBuilder",
|
|
18
|
+
"any_state",
|
|
19
|
+
"default_state",
|
|
20
|
+
)
|
trueconf/fsm/context.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any, overload
|
|
5
|
+
|
|
6
|
+
from trueconf.fsm.key_builder import StorageKey
|
|
7
|
+
from trueconf.fsm.state import State
|
|
8
|
+
from trueconf.fsm.storage.base import BaseStorage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FSMContext:
|
|
12
|
+
def __init__(self, storage: BaseStorage, key: StorageKey) -> None:
|
|
13
|
+
self._storage = storage
|
|
14
|
+
self._key = key
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def key(self) -> StorageKey:
|
|
18
|
+
return self._key
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def storage(self) -> BaseStorage:
|
|
22
|
+
return self._storage
|
|
23
|
+
|
|
24
|
+
async def get_state(self) -> str | None:
|
|
25
|
+
return await self._storage.get_state(self._key)
|
|
26
|
+
|
|
27
|
+
async def set_state(self, state: State | str | None) -> None:
|
|
28
|
+
value = str(state) if isinstance(state, State) else state
|
|
29
|
+
await self._storage.set_state(self._key, value)
|
|
30
|
+
|
|
31
|
+
async def get_data(self) -> dict[str, Any]:
|
|
32
|
+
return await self._storage.get_data(self._key)
|
|
33
|
+
|
|
34
|
+
async def set_data(self, data: Mapping[str, Any]) -> None:
|
|
35
|
+
await self._storage.set_data(self._key, dict(data))
|
|
36
|
+
|
|
37
|
+
@overload
|
|
38
|
+
async def get_value(self, key: str) -> Any | None: ...
|
|
39
|
+
|
|
40
|
+
@overload
|
|
41
|
+
async def get_value(self, key: str, default: Any) -> Any: ...
|
|
42
|
+
|
|
43
|
+
async def get_value(self, key: str, default: Any | None = None) -> Any | None:
|
|
44
|
+
return await self._storage.get_value(self._key, key, default)
|
|
45
|
+
|
|
46
|
+
async def update_data(
|
|
47
|
+
self,
|
|
48
|
+
data: Mapping[str, Any] | None = None,
|
|
49
|
+
**kwargs: Any,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
if data:
|
|
52
|
+
kwargs.update(data)
|
|
53
|
+
return await self._storage.update_data(self._key, kwargs)
|
|
54
|
+
|
|
55
|
+
async def clear(self) -> None:
|
|
56
|
+
await self.set_state(None)
|
|
57
|
+
await self.set_data({})
|
trueconf/fsm/filters.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from trueconf.fsm.context import FSMContext
|
|
6
|
+
from trueconf.fsm.state import State
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StateFilter:
|
|
10
|
+
def __init__(self, *states: State | str | None) -> None:
|
|
11
|
+
self._states: set[str | None] = {
|
|
12
|
+
str(s) if isinstance(s, State) else s for s in states
|
|
13
|
+
}
|
|
14
|
+
self._wildcard = "*" in self._states
|
|
15
|
+
|
|
16
|
+
async def __call__(
|
|
17
|
+
self,
|
|
18
|
+
event: Any,
|
|
19
|
+
*,
|
|
20
|
+
state: FSMContext | None = None,
|
|
21
|
+
) -> bool:
|
|
22
|
+
if state is None:
|
|
23
|
+
raise RuntimeError(
|
|
24
|
+
"StateFilter requires FSMContext in data['state'], but it was not found. "
|
|
25
|
+
"Make sure FSMMiddleware is registered as outer middleware "
|
|
26
|
+
"before any StateFilter is evaluated."
|
|
27
|
+
)
|
|
28
|
+
if self._wildcard:
|
|
29
|
+
return True
|
|
30
|
+
current = await state.get_state()
|
|
31
|
+
return current in self._states
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class StorageKey:
|
|
9
|
+
bot_id: str | None
|
|
10
|
+
chat_id: str
|
|
11
|
+
user_id: str
|
|
12
|
+
destiny: str = "default"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class KeyBuilder(Protocol):
|
|
17
|
+
def build(self, bot: Any, event: Any) -> StorageKey: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DefaultKeyBuilder:
|
|
21
|
+
def build(self, bot: Any, event: Any) -> StorageKey:
|
|
22
|
+
bot_id = getattr(bot, "me_id", None) or getattr(bot, "id", None)
|
|
23
|
+
|
|
24
|
+
chat_id = (
|
|
25
|
+
getattr(event, "chat_id", None)
|
|
26
|
+
or getattr(getattr(event, "chat", None), "id", None)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
user = getattr(event, "from_user", None) or getattr(event, "author", None)
|
|
30
|
+
user_id = getattr(user, "id", None) if user else None
|
|
31
|
+
|
|
32
|
+
if chat_id is None or user_id is None:
|
|
33
|
+
raise RuntimeError(
|
|
34
|
+
f"Cannot build FSM StorageKey: event of type {type(event).__name__} "
|
|
35
|
+
f"has no chat_id ({chat_id}) or user_id ({user_id}). "
|
|
36
|
+
f"Provide a custom KeyBuilder to Dispatcher.setup_fsm()."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return StorageKey(bot_id=bot_id, chat_id=str(chat_id), user_id=str(user_id))
|
trueconf/fsm/manager.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from trueconf.fsm.context import FSMContext
|
|
6
|
+
from trueconf.fsm.key_builder import DefaultKeyBuilder, KeyBuilder, StorageKey
|
|
7
|
+
from trueconf.fsm.storage.base import BaseStorage
|
|
8
|
+
from trueconf.fsm.storage.memory import MemoryStorage
|
|
9
|
+
from trueconf.fsm.strategy import FSMStrategy, apply_strategy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FSMManager:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
storage: BaseStorage | None = None,
|
|
16
|
+
key_builder: KeyBuilder | None = None,
|
|
17
|
+
strategy: FSMStrategy = FSMStrategy.USER_IN_CHAT,
|
|
18
|
+
) -> None:
|
|
19
|
+
self.storage: BaseStorage = storage or MemoryStorage()
|
|
20
|
+
self.key_builder: KeyBuilder = key_builder or DefaultKeyBuilder()
|
|
21
|
+
self.strategy = strategy
|
|
22
|
+
|
|
23
|
+
def get_context(self, bot: Any, event: Any) -> FSMContext:
|
|
24
|
+
key = self.key_builder.build(bot, event)
|
|
25
|
+
chat_id, user_id = apply_strategy(
|
|
26
|
+
self.strategy,
|
|
27
|
+
key.chat_id,
|
|
28
|
+
key.user_id,
|
|
29
|
+
)
|
|
30
|
+
adjusted_key = StorageKey(
|
|
31
|
+
bot_id=key.bot_id,
|
|
32
|
+
chat_id=chat_id,
|
|
33
|
+
user_id=user_id,
|
|
34
|
+
destiny=key.destiny,
|
|
35
|
+
)
|
|
36
|
+
return FSMContext(self.storage, adjusted_key)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from trueconf.fsm.manager import FSMManager
|
|
6
|
+
from trueconf.middleware import BaseMiddleware
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FSMMiddleware(BaseMiddleware):
|
|
10
|
+
def __init__(self, fsm_manager: FSMManager) -> None:
|
|
11
|
+
self._manager = fsm_manager
|
|
12
|
+
|
|
13
|
+
async def __call__(
|
|
14
|
+
self,
|
|
15
|
+
handler: Any,
|
|
16
|
+
event: Any,
|
|
17
|
+
data: dict[str, Any],
|
|
18
|
+
) -> None:
|
|
19
|
+
bot = data.get("bot")
|
|
20
|
+
if bot is not None:
|
|
21
|
+
context = self._manager.get_context(bot, event)
|
|
22
|
+
data["state"] = context
|
|
23
|
+
data["raw_state"] = await context.get_state()
|
|
24
|
+
return await handler(event, data)
|
trueconf/fsm/state.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class State:
|
|
8
|
+
def __init__(self, state: str | None = None) -> None:
|
|
9
|
+
self._custom_state = state
|
|
10
|
+
self._name: str | None = None
|
|
11
|
+
self._group: type[StatesGroup] | None = None
|
|
12
|
+
|
|
13
|
+
def bind(self, group: type[StatesGroup], name: str) -> None:
|
|
14
|
+
if self._group is not None or self._name is not None:
|
|
15
|
+
raise RuntimeError(
|
|
16
|
+
f"State '{self._name}' is already bound to {self._group}. "
|
|
17
|
+
f"Cannot rebind to {group.__name__}:{name}."
|
|
18
|
+
)
|
|
19
|
+
self._group = group
|
|
20
|
+
self._name = name
|
|
21
|
+
|
|
22
|
+
def __str__(self) -> str:
|
|
23
|
+
if self._custom_state is not None:
|
|
24
|
+
return self._custom_state
|
|
25
|
+
if self._group is None or self._name is None:
|
|
26
|
+
raise RuntimeError(
|
|
27
|
+
"State is not bound to a StatesGroup. "
|
|
28
|
+
"Use State inside a StatesGroup class definition, "
|
|
29
|
+
"or pass a custom string: State('my:state')."
|
|
30
|
+
)
|
|
31
|
+
return f"{self._group.__full_group_name__}:{self._name}"
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str:
|
|
34
|
+
return f"State({str(self)!r})"
|
|
35
|
+
|
|
36
|
+
def __eq__(self, other: object) -> bool:
|
|
37
|
+
if isinstance(other, State):
|
|
38
|
+
return str(self) == str(other)
|
|
39
|
+
if isinstance(other, str):
|
|
40
|
+
return str(self) == other
|
|
41
|
+
return NotImplemented
|
|
42
|
+
|
|
43
|
+
def __hash__(self) -> int:
|
|
44
|
+
return hash(str(self))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _StatesGroupMeta(type):
|
|
48
|
+
__parent__: type[StatesGroup] | None
|
|
49
|
+
__childs__: tuple[type[StatesGroup], ...]
|
|
50
|
+
__states__: tuple[State, ...]
|
|
51
|
+
__all_states__: tuple[State, ...]
|
|
52
|
+
__all_childs__: tuple[type[StatesGroup], ...]
|
|
53
|
+
|
|
54
|
+
def __new__(
|
|
55
|
+
mcs,
|
|
56
|
+
name: str,
|
|
57
|
+
bases: tuple[type, ...],
|
|
58
|
+
namespace: dict[str, Any],
|
|
59
|
+
) -> _StatesGroupMeta:
|
|
60
|
+
cls = super().__new__(mcs, name, bases, namespace)
|
|
61
|
+
|
|
62
|
+
states: list[State] = []
|
|
63
|
+
childs: list[type[StatesGroup]] = []
|
|
64
|
+
|
|
65
|
+
for key, value in namespace.items():
|
|
66
|
+
if isinstance(value, State):
|
|
67
|
+
value.bind(cls, key)
|
|
68
|
+
states.append(value)
|
|
69
|
+
elif (
|
|
70
|
+
inspect.isclass(value)
|
|
71
|
+
and issubclass(value, StatesGroup)
|
|
72
|
+
and value is not StatesGroup
|
|
73
|
+
):
|
|
74
|
+
child = cls._prepare_child(value)
|
|
75
|
+
childs.append(child)
|
|
76
|
+
|
|
77
|
+
cls.__parent__ = None # type: ignore[attr-defined]
|
|
78
|
+
cls.__childs__ = tuple(childs) # type: ignore[attr-defined]
|
|
79
|
+
cls.__states__ = tuple(states) # type: ignore[attr-defined]
|
|
80
|
+
cls.__all_childs__ = cls._get_all_childs() # type: ignore[attr-defined]
|
|
81
|
+
cls.__all_states__ = cls._get_all_states() # type: ignore[attr-defined]
|
|
82
|
+
|
|
83
|
+
return cls
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def __full_group_name__(cls) -> str: # type: ignore[override]
|
|
87
|
+
if cls.__parent__ is not None:
|
|
88
|
+
return f"{cls.__parent__.__full_group_name__}.{cls.__name__}"
|
|
89
|
+
return cls.__name__
|
|
90
|
+
|
|
91
|
+
def _prepare_child(cls, child: type[StatesGroup]) -> type[StatesGroup]:
|
|
92
|
+
child.__parent__ = cls # type: ignore[assignment]
|
|
93
|
+
return child
|
|
94
|
+
|
|
95
|
+
def _get_all_childs(cls) -> tuple[type[StatesGroup], ...]:
|
|
96
|
+
result: list[type[StatesGroup]] = list(cls.__childs__)
|
|
97
|
+
for child in cls.__childs__:
|
|
98
|
+
result.extend(child.__childs__)
|
|
99
|
+
return tuple(result)
|
|
100
|
+
|
|
101
|
+
def _get_all_states(cls) -> tuple[State, ...]:
|
|
102
|
+
result: list[State] = list(cls.__states__)
|
|
103
|
+
for child in cls.__childs__:
|
|
104
|
+
result.extend(child.__all_states__)
|
|
105
|
+
return tuple(result)
|
|
106
|
+
|
|
107
|
+
def __contains__(cls, item: Any) -> bool:
|
|
108
|
+
if isinstance(item, str):
|
|
109
|
+
return item in tuple(str(s) for s in cls.__all_states__)
|
|
110
|
+
if isinstance(item, State):
|
|
111
|
+
return item in cls.__all_states__
|
|
112
|
+
if isinstance(item, _StatesGroupMeta):
|
|
113
|
+
return item in cls.__all_childs__
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
def __iter__(cls) -> Any:
|
|
117
|
+
return iter(cls.__all_states__)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class StatesGroup(metaclass=_StatesGroupMeta):
|
|
121
|
+
__parent__: type[StatesGroup] | None = None
|
|
122
|
+
__childs__: tuple[type[StatesGroup], ...] = ()
|
|
123
|
+
__states__: tuple[State, ...] = ()
|
|
124
|
+
__all_states__: tuple[State, ...] = ()
|
|
125
|
+
__all_childs__: tuple[type[StatesGroup], ...] = ()
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def get_root(cls) -> type[StatesGroup]:
|
|
129
|
+
if cls.__parent__ is None:
|
|
130
|
+
return cls
|
|
131
|
+
return cls.__parent__.get_root()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
any_state = State(state="*")
|
|
135
|
+
default_state = State()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from trueconf.fsm.key_builder import StorageKey
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BaseStorage(ABC):
|
|
8
|
+
@abstractmethod
|
|
9
|
+
async def get_state(self, key: StorageKey) -> str | None: ...
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def set_state(self, key: StorageKey, state: str | None) -> None: ...
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
async def get_data(self, key: StorageKey) -> dict[str, Any]: ...
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
async def set_data(self, key: StorageKey, data: dict[str, Any]) -> None: ...
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def update_data(self, key: StorageKey, updates: dict[str, Any]) -> dict[str, Any]: ...
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
async def clear(self, key: StorageKey) -> None: ...
|
|
25
|
+
|
|
26
|
+
async def get_value(
|
|
27
|
+
self,
|
|
28
|
+
key: StorageKey,
|
|
29
|
+
dict_key: str,
|
|
30
|
+
default: Any | None = None,
|
|
31
|
+
) -> Any | None:
|
|
32
|
+
data = await self.get_data(key)
|
|
33
|
+
return data.get(dict_key, default)
|
|
34
|
+
|
|
35
|
+
async def close(self) -> None:
|
|
36
|
+
pass
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import copy
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from trueconf.fsm.key_builder import StorageKey
|
|
8
|
+
from trueconf.fsm.storage.base import BaseStorage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class _Record:
|
|
13
|
+
state: str | None = None
|
|
14
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MemoryStorage(BaseStorage):
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._records: dict[StorageKey, _Record] = {}
|
|
20
|
+
|
|
21
|
+
def _get_or_create(self, key: StorageKey) -> _Record:
|
|
22
|
+
if key not in self._records:
|
|
23
|
+
self._records[key] = _Record()
|
|
24
|
+
return self._records[key]
|
|
25
|
+
|
|
26
|
+
async def get_state(self, key: StorageKey) -> str | None:
|
|
27
|
+
record = self._records.get(key)
|
|
28
|
+
return record.state if record else None
|
|
29
|
+
|
|
30
|
+
async def set_state(self, key: StorageKey, state: str | None) -> None:
|
|
31
|
+
self._get_or_create(key).state = state
|
|
32
|
+
|
|
33
|
+
async def get_data(self, key: StorageKey) -> dict[str, Any]:
|
|
34
|
+
record = self._records.get(key)
|
|
35
|
+
return record.data.copy() if record else {}
|
|
36
|
+
|
|
37
|
+
async def set_data(self, key: StorageKey, data: dict[str, Any]) -> None:
|
|
38
|
+
self._get_or_create(key).data = data.copy()
|
|
39
|
+
|
|
40
|
+
async def update_data(self, key: StorageKey, updates: dict[str, Any]) -> dict[str, Any]:
|
|
41
|
+
record = self._get_or_create(key)
|
|
42
|
+
record.data.update(updates)
|
|
43
|
+
return record.data.copy()
|
|
44
|
+
|
|
45
|
+
async def clear(self, key: StorageKey) -> None:
|
|
46
|
+
self._records.pop(key, None)
|
|
47
|
+
|
|
48
|
+
async def get_value(
|
|
49
|
+
self,
|
|
50
|
+
key: StorageKey,
|
|
51
|
+
dict_key: str,
|
|
52
|
+
default: Any | None = None,
|
|
53
|
+
) -> Any | None:
|
|
54
|
+
record = self._records.get(key)
|
|
55
|
+
if record is None:
|
|
56
|
+
return default
|
|
57
|
+
return copy(record.data.get(dict_key, default))
|
trueconf/fsm/strategy.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum, auto
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FSMStrategy(Enum):
|
|
7
|
+
"""FSM strategy for storage key generation.
|
|
8
|
+
|
|
9
|
+
Determines how chat_id and user_id are combined to form the storage key.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
USER_IN_CHAT = auto()
|
|
13
|
+
"""State is stored per user per chat. Default behavior."""
|
|
14
|
+
|
|
15
|
+
CHAT = auto()
|
|
16
|
+
"""State is stored per chat (all users in a chat share the same state)."""
|
|
17
|
+
|
|
18
|
+
GLOBAL_USER = auto()
|
|
19
|
+
"""State is stored per user globally (across all chats)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def apply_strategy(
|
|
23
|
+
strategy: FSMStrategy,
|
|
24
|
+
chat_id: str,
|
|
25
|
+
user_id: str,
|
|
26
|
+
) -> tuple[str, str]:
|
|
27
|
+
"""Apply FSM strategy to chat_id and user_id.
|
|
28
|
+
|
|
29
|
+
Returns (effective_chat_id, effective_user_id) for StorageKey construction.
|
|
30
|
+
"""
|
|
31
|
+
if strategy == FSMStrategy.CHAT:
|
|
32
|
+
return chat_id, chat_id
|
|
33
|
+
if strategy == FSMStrategy.GLOBAL_USER:
|
|
34
|
+
return user_id, user_id
|
|
35
|
+
# USER_IN_CHAT (default)
|
|
36
|
+
return chat_id, user_id
|
trueconf/middleware.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import logging
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict
|
|
4
|
+
|
|
5
|
+
from trueconf.filters.base import Event
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("chat_bot")
|
|
11
|
+
|
|
12
|
+
MiddlewareHandler = Callable[[Event, Dict[str, Any]], Awaitable[None]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseMiddleware:
|
|
16
|
+
"""
|
|
17
|
+
Base middleware class for event processing pipeline.
|
|
18
|
+
|
|
19
|
+
Middleware wraps event handlers and can intercept, modify, or block events
|
|
20
|
+
before they reach the actual handler. To block an event, simply do not call
|
|
21
|
+
``await handler(event, data)``.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
>>> class LoggingMiddleware(BaseMiddleware):
|
|
25
|
+
... async def __call__(self, handler, event, data):
|
|
26
|
+
... logger.info(f"Event: {event}")
|
|
27
|
+
... await handler(event, data)
|
|
28
|
+
... logger.info("Done")
|
|
29
|
+
|
|
30
|
+
>>> router.outer_middleware(LoggingMiddleware())
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
async def __call__(
|
|
34
|
+
self,
|
|
35
|
+
handler: MiddlewareHandler,
|
|
36
|
+
event: Event,
|
|
37
|
+
data: Dict[str, Any],
|
|
38
|
+
) -> None:
|
|
39
|
+
await handler(event, data)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SkipSelfMessages(BaseMiddleware):
|
|
43
|
+
"""
|
|
44
|
+
Middleware that drops messages sent by the bot itself.
|
|
45
|
+
|
|
46
|
+
Prevents the bot from reacting to its own messages when multiple bot sessions
|
|
47
|
+
are running. If the event is a ``Message`` and its ``author.id`` matches
|
|
48
|
+
``bot.me_id``, the event is silently dropped.
|
|
49
|
+
|
|
50
|
+
Registration:
|
|
51
|
+
Automatically registered when ``Bot(skip_self_messages=True)`` (default).
|
|
52
|
+
Alternatively:
|
|
53
|
+
>>> from trueconf.middleware import SkipSelfMessages
|
|
54
|
+
>>> dp.outer_middleware(SkipSelfMessages())
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
async def __call__(
|
|
58
|
+
self,
|
|
59
|
+
handler: MiddlewareHandler,
|
|
60
|
+
event: Event,
|
|
61
|
+
data: Dict[str, Any],
|
|
62
|
+
) -> None:
|
|
63
|
+
bot = data.get("bot")
|
|
64
|
+
if bot is None:
|
|
65
|
+
await handler(event, data)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
from trueconf.types.message import Message
|
|
69
|
+
|
|
70
|
+
if isinstance(event, Message) and event.author.id == bot.me_id:
|
|
71
|
+
logger.debug(
|
|
72
|
+
"[SkipSelfMessages] dropped message %s from bot self (author=%s)",
|
|
73
|
+
event.message_id,
|
|
74
|
+
event.author.id,
|
|
75
|
+
)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
await handler(event, data)
|
|
File without changes
|
{python_trueconf_bot-1.3.0.dist-info → python_trueconf_bot-1.4.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|