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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-trueconf-bot
3
- Version: 1.3.0
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.3.0.dist-info/licenses/LICENSE,sha256=ftDuEzhr-yY_dsfpEQ2SKAP_dEAO-iPt96MBZq_K48o,1684
2
- trueconf/__init__.py,sha256=JRFC0ca_JhwlDuzcnZ1r9bflnAmQyMYhJr_hBRYcdpw,394
3
- trueconf/_version.py,sha256=kjMXs_RelAfH3nsfXcggy4eqwR8yDdd160wJ9EHB63g,520
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=CCtWyKrmxQxETBSD31sd4r6I5_L4eWHgh4sNXfNYBUI,78540
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=3l0CVXWj5pPIzN2jd4O6aClZ4LS0Gm0E55sM8P_WMOs,2114
13
- trueconf/dispatcher/router.py,sha256=Oqwfq854565a0kVSsa_0QkdTIR3g0T8YzSkqnAae-oQ,15843
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.3.0.dist-info/METADATA,sha256=N8b9bbvbyice_Egb7QWBYOmi_e_fgrkJGdCQli0A8rY,5700
147
- python_trueconf_bot-1.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
148
- python_trueconf_bot-1.3.0.dist-info/top_level.txt,sha256=GjgYdn_osTUuU-H8f59VhIS7MY44xLV7-D7veywJaeY,9
149
- python_trueconf_bot-1.3.0.dist-info/RECORD,,
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.3.0'
22
- __version_tuple__ = version_tuple = (1, 3, 0)
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
- verify_ssl (bool | str | ssl.SSLContext, optional): SSL verification mode. If True, verifies
349
- the server certificate using the system trust store when available. If False, disables
350
- certificate verification. If a string is provided, it must be a path to a CA bundle file.
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. The callback receives a dictionary with the current status,
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 = 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
- class Dispatcher:
8
- """
9
- Central event dispatcher for processing and routing incoming events.
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
- Typical usage includes registering routers with handlers and then calling
17
- `feed_update()` with incoming events.
15
+ class Dispatcher(Router):
16
+ """Central dispatcher for routing incoming events.
18
17
 
19
- Examples:
20
- >>> dispatcher = Dispatcher()
21
- >>> dispatcher.include_router(my_router)
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
- Attributes:
24
- routers (List[Router]): List of root routers included in the dispatcher.
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
- def __init__(self):
29
- """Initializes an empty dispatcher with no routers."""
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
- def include_router(self, router: Router):
33
- """
34
- Includes a router to be used by the dispatcher.
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
- Args:
37
- router (Router): A `Router` instance to include.
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 and subrouters in order,
119
+ Feeds an event to all child routers in order,
44
120
  stopping at the first one that handles it.
45
121
 
46
- Args:
47
- event (Event): The event to be processed.
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
- Returns:
50
- None
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 progress_router(router, count = 0):
54
- handled = await router._feed(event)
55
- if count < 0 or count >= len(router._subrouters):
56
- return
57
- if (not handled) or (handled and router.allow_child_on_event):
58
- subrouter = router._subrouters[count]
59
- return await progress_router(subrouter, count=len(router._subrouters) - 1)
60
- return
61
-
62
- for router in self.routers:
63
- await progress_router(router)
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)
@@ -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__(self, name: str | None = None, allow_child_on_event: bool = False):
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
- if not flts:
100
- self._spawn(handler, event, "<none>")
101
- return True
141
+ Pipeline:
142
+ outer_middleware → filter match → inner_middleware → handler
102
143
 
103
- matched = True
104
- for f in flts:
105
- try:
106
- if not await self._apply_filter(f, event):
107
- matched = False
108
- break
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
- if matched:
115
- filters_str = ", ".join(
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
- result = await self._apply_filter(f, event)
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
- self._spawn(handler, event, filters_str, **kwargs)
127
- return True
128
- return False
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 (sync or async) against the event."""
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
- res = f(event)
153
- except Exception:
154
- return False
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
- try:
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):
@@ -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
+ )
@@ -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({})
@@ -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))
@@ -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,4 @@
1
+ from trueconf.fsm.storage.base import BaseStorage
2
+ from trueconf.fsm.storage.memory import MemoryStorage
3
+
4
+ __all__ = ("BaseStorage", "MemoryStorage")
@@ -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))
@@ -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)