python-trueconf-bot 1.2.3.dev1__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.2.3.dev1.dist-info → python_trueconf_bot-1.4.0.dist-info}/METADATA +2 -1
- {python_trueconf_bot-1.2.3.dev1.dist-info → python_trueconf_bot-1.4.0.dist-info}/RECORD +30 -14
- trueconf/__init__.py +2 -0
- trueconf/_version.py +2 -2
- trueconf/client/bot.py +193 -37
- trueconf/dispatcher/dispatcher.py +126 -40
- trueconf/dispatcher/router.py +156 -38
- trueconf/exceptions.py +107 -10
- 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
- trueconf/types/message.py +57 -21
- trueconf/utils/_ssl.py +44 -0
- trueconf/utils/_token.py +4 -4
- trueconf/utils/_version_checker.py +6 -0
- trueconf/utils/formatting/__init__.py +21 -0
- trueconf/utils/formatting/decorations.py +87 -0
- trueconf/utils/formatting/text.py +252 -0
- {python_trueconf_bot-1.2.3.dev1.dist-info → python_trueconf_bot-1.4.0.dist-info}/WHEEL +0 -0
- {python_trueconf_bot-1.2.3.dev1.dist-info → python_trueconf_bot-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {python_trueconf_bot-1.2.3.dev1.dist-info → python_trueconf_bot-1.4.0.dist-info}/top_level.txt +0 -0
|
@@ -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/exceptions.py
CHANGED
|
@@ -1,19 +1,41 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
1
|
class TrueConfChatBotError(Exception):
|
|
5
2
|
"""
|
|
6
3
|
Base exception for all TrueConf ChatBot Connector errors.
|
|
4
|
+
|
|
5
|
+
All custom exceptions raised by the library inherit from this class.
|
|
6
|
+
You can catch this exception to handle any library-specific error in a
|
|
7
|
+
single place.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
class TokenValidationError(TrueConfChatBotError):
|
|
11
|
+
"""
|
|
12
|
+
Raised when the bot token fails local validation.
|
|
13
|
+
|
|
14
|
+
This error indicates that the token value has an invalid format or
|
|
15
|
+
cannot be used by the connector before the authorization request is sent.
|
|
16
|
+
"""
|
|
10
17
|
pass
|
|
11
18
|
|
|
12
19
|
class InvalidGrantError(TrueConfChatBotError):
|
|
20
|
+
"""
|
|
21
|
+
Raised when the server rejects the provided authorization grant.
|
|
22
|
+
|
|
23
|
+
This usually means that the token or credentials are invalid, expired,
|
|
24
|
+
revoked, or cannot be used to authorize the bot.
|
|
25
|
+
"""
|
|
13
26
|
pass
|
|
14
27
|
|
|
15
28
|
class LimitExceededError(TrueConfChatBotError):
|
|
16
|
-
"""
|
|
29
|
+
"""
|
|
30
|
+
Base exception for all limit-related errors.
|
|
31
|
+
|
|
32
|
+
This class is used for errors related to text length, title length,
|
|
33
|
+
file size, and other numeric limits.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
actual_value: Actual value that exceeded the limit.
|
|
37
|
+
limit: Maximum allowed value.
|
|
38
|
+
"""
|
|
17
39
|
def __init__(self, message: str, actual_value: int, limit: int):
|
|
18
40
|
super().__init__(message)
|
|
19
41
|
self.actual_value = actual_value
|
|
@@ -21,7 +43,17 @@ class LimitExceededError(TrueConfChatBotError):
|
|
|
21
43
|
|
|
22
44
|
|
|
23
45
|
class TextMessageTooLongError(LimitExceededError):
|
|
24
|
-
"""
|
|
46
|
+
"""
|
|
47
|
+
Raised when a text message exceeds the maximum allowed length.
|
|
48
|
+
|
|
49
|
+
The default text message limit is 4096 visible characters. For long texts,
|
|
50
|
+
such as LLM-generated responses, use `safe_split_text` from `trueconf.utils`
|
|
51
|
+
and send the returned chunks one by one.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
actual_value: Actual visible length of the message text.
|
|
55
|
+
limit: Maximum allowed message length.
|
|
56
|
+
"""
|
|
25
57
|
def __init__(self, actual_length: int, limit: int = 4096):
|
|
26
58
|
message = (
|
|
27
59
|
f"Bad Request: text message is too long. "
|
|
@@ -31,7 +63,17 @@ class TextMessageTooLongError(LimitExceededError):
|
|
|
31
63
|
super().__init__(message, actual_length, limit)
|
|
32
64
|
|
|
33
65
|
class FileCaptionTooLongError(LimitExceededError):
|
|
34
|
-
"""
|
|
66
|
+
"""
|
|
67
|
+
Raised when a file caption exceeds the maximum allowed length.
|
|
68
|
+
|
|
69
|
+
The default caption limit is 4096 visible characters. For long captions,
|
|
70
|
+
use `safe_split_text` from `trueconf.utils` and send the returned chunks
|
|
71
|
+
separately when appropriate.
|
|
72
|
+
|
|
73
|
+
Attributes:
|
|
74
|
+
actual_value: Actual visible length of the caption.
|
|
75
|
+
limit: Maximum allowed caption length.
|
|
76
|
+
"""
|
|
35
77
|
def __init__(self, actual_length: int, limit: int = 4096):
|
|
36
78
|
message = (
|
|
37
79
|
f"Bad Request: caption is too long. "
|
|
@@ -41,11 +83,22 @@ class FileCaptionTooLongError(LimitExceededError):
|
|
|
41
83
|
super().__init__(message, actual_length, limit)
|
|
42
84
|
|
|
43
85
|
class ChatTitleTooLongError(LimitExceededError):
|
|
44
|
-
"""
|
|
86
|
+
"""
|
|
87
|
+
Base exception for chat title length errors.
|
|
88
|
+
|
|
89
|
+
This class is inherited by more specific exceptions for group and channel
|
|
90
|
+
title length validation.
|
|
91
|
+
"""
|
|
45
92
|
pass
|
|
46
93
|
|
|
47
94
|
class GroupTitleTooLongError(ChatTitleTooLongError):
|
|
48
|
-
"""
|
|
95
|
+
"""
|
|
96
|
+
Raised when a group title exceeds the maximum allowed length.
|
|
97
|
+
|
|
98
|
+
Attributes:
|
|
99
|
+
actual_value: Actual length of the group title.
|
|
100
|
+
limit: Maximum allowed group title length.
|
|
101
|
+
"""
|
|
49
102
|
def __init__(self, actual_length: int, limit: int = 256):
|
|
50
103
|
message = (
|
|
51
104
|
f"Bad Request: group title is too long. "
|
|
@@ -54,7 +107,13 @@ class GroupTitleTooLongError(ChatTitleTooLongError):
|
|
|
54
107
|
super().__init__(message, actual_length, limit)
|
|
55
108
|
|
|
56
109
|
class ChannelTitleTooLongError(ChatTitleTooLongError):
|
|
57
|
-
"""
|
|
110
|
+
"""
|
|
111
|
+
Raised when a channel title exceeds the maximum allowed length.
|
|
112
|
+
|
|
113
|
+
Attributes:
|
|
114
|
+
actual_value: Actual length of the channel title.
|
|
115
|
+
limit: Maximum allowed channel title length.
|
|
116
|
+
"""
|
|
58
117
|
def __init__(self, actual_length: int, limit: int = 256):
|
|
59
118
|
message = (
|
|
60
119
|
f"Bad Request: channel title is too long. "
|
|
@@ -63,10 +122,25 @@ class ChannelTitleTooLongError(ChatTitleTooLongError):
|
|
|
63
122
|
super().__init__(message, actual_length, limit)
|
|
64
123
|
|
|
65
124
|
class FileValidationError(TrueConfChatBotError):
|
|
66
|
-
"""
|
|
125
|
+
"""
|
|
126
|
+
Base exception for file validation errors.
|
|
127
|
+
|
|
128
|
+
This class is used for errors related to file size limits, extension filters,
|
|
129
|
+
and other file validation rules received from TrueConf Server.
|
|
130
|
+
"""
|
|
67
131
|
pass
|
|
68
132
|
|
|
69
133
|
class FileSizeTooLargeError(FileValidationError, LimitExceededError):
|
|
134
|
+
"""
|
|
135
|
+
Raised when a file exceeds the maximum size allowed by the server.
|
|
136
|
+
|
|
137
|
+
File size limits are configured by the TrueConf Server administrator and
|
|
138
|
+
are checked by the bot before sending a file.
|
|
139
|
+
|
|
140
|
+
Attributes:
|
|
141
|
+
actual_value: Actual file size in bytes.
|
|
142
|
+
limit: Maximum allowed file size in bytes.
|
|
143
|
+
"""
|
|
70
144
|
def __init__(self, actual_size: int, limit: int):
|
|
71
145
|
actual_mb = round(actual_size / (1024 * 1024), 2)
|
|
72
146
|
limit_mb = round(limit / (1024 * 1024), 2)
|
|
@@ -78,6 +152,18 @@ class FileSizeTooLargeError(FileValidationError, LimitExceededError):
|
|
|
78
152
|
|
|
79
153
|
|
|
80
154
|
class InvalidFileExtensionError(FileValidationError):
|
|
155
|
+
"""
|
|
156
|
+
Raised when a file extension does not satisfy the server extension filter.
|
|
157
|
+
|
|
158
|
+
File extension rules are configured by the TrueConf Server administrator.
|
|
159
|
+
Depending on the filter mode, the extension list can work either as an
|
|
160
|
+
allow list or as a block list.
|
|
161
|
+
|
|
162
|
+
Attributes:
|
|
163
|
+
extension: File extension that failed validation.
|
|
164
|
+
extensions: Set of extensions used for validation.
|
|
165
|
+
mode: Extension filter mode.
|
|
166
|
+
"""
|
|
81
167
|
def __init__(self, extension: str, extensions: set[str] | None = None, mode: str = "allow"):
|
|
82
168
|
self.extension = extension
|
|
83
169
|
self.extensions = extensions or set()
|
|
@@ -106,6 +192,17 @@ class InvalidFileExtensionError(FileValidationError):
|
|
|
106
192
|
super().__init__(message)
|
|
107
193
|
|
|
108
194
|
class ApiErrorException(TrueConfChatBotError):
|
|
195
|
+
"""
|
|
196
|
+
Raised when TrueConf Server returns an API error response.
|
|
197
|
+
|
|
198
|
+
This exception stores the server error code, human-readable detail, and
|
|
199
|
+
optional response payload for additional context.
|
|
200
|
+
|
|
201
|
+
Attributes:
|
|
202
|
+
code: Error code returned by the server.
|
|
203
|
+
detail: Human-readable error description.
|
|
204
|
+
payload: Raw error payload returned by the server, or an empty dict.
|
|
205
|
+
"""
|
|
109
206
|
def __init__(self, code: int, detail: str, payload: dict | None = None):
|
|
110
207
|
super().__init__(f"[{code}] {detail}")
|
|
111
208
|
self.code = code
|
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
|
+
)
|