codex-bot 0.1.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.
- codex_bot/__init__.py +24 -0
- codex_bot/animation/__init__.py +10 -0
- codex_bot/animation/animation_service.py +298 -0
- codex_bot/base/__init__.py +22 -0
- codex_bot/base/base_orchestrator.py +123 -0
- codex_bot/base/context_dto.py +42 -0
- codex_bot/base/view_dto.py +93 -0
- codex_bot/cli/__init__.py +11 -0
- codex_bot/cli/commands.py +215 -0
- codex_bot/director/__init__.py +13 -0
- codex_bot/director/director.py +107 -0
- codex_bot/director/protocols.py +80 -0
- codex_bot/engine/__init__.py +13 -0
- codex_bot/engine/discovery/__init__.py +7 -0
- codex_bot/engine/discovery/service.py +258 -0
- codex_bot/engine/factory/__init__.py +7 -0
- codex_bot/engine/factory/bot_builder.py +111 -0
- codex_bot/engine/http/__init__.py +10 -0
- codex_bot/engine/http/api_client.py +123 -0
- codex_bot/engine/i18n/__init__.py +7 -0
- codex_bot/engine/i18n/locales_compiler.py +83 -0
- codex_bot/engine/middlewares/__init__.py +24 -0
- codex_bot/engine/middlewares/container.py +46 -0
- codex_bot/engine/middlewares/i18n.py +90 -0
- codex_bot/engine/middlewares/throttling.py +69 -0
- codex_bot/engine/middlewares/user_validation.py +49 -0
- codex_bot/engine/router_builder/__init__.py +10 -0
- codex_bot/engine/router_builder/router_builder.py +133 -0
- codex_bot/fsm/__init__.py +16 -0
- codex_bot/fsm/common_fsm_handlers.py +46 -0
- codex_bot/fsm/garbage_collector.py +141 -0
- codex_bot/fsm/state_helper.py +72 -0
- codex_bot/fsm/state_manager.py +104 -0
- codex_bot/helper/__init__.py +7 -0
- codex_bot/helper/context_helper.py +61 -0
- codex_bot/redis/__init__.py +15 -0
- codex_bot/redis/dispatcher.py +169 -0
- codex_bot/redis/router.py +71 -0
- codex_bot/redis/stream_processor.py +210 -0
- codex_bot/sender/__init__.py +15 -0
- codex_bot/sender/protocols.py +67 -0
- codex_bot/sender/sender_keys.py +50 -0
- codex_bot/sender/sender_manager.py +85 -0
- codex_bot/sender/view_sender.py +200 -0
- codex_bot/templates/feature/callbacks.py.tpl +14 -0
- codex_bot/templates/feature/contract.py.tpl +12 -0
- codex_bot/templates/feature/dto.py.tpl +7 -0
- codex_bot/templates/feature/feature.py.tpl +26 -0
- codex_bot/templates/feature/feature_redis.py.tpl +6 -0
- codex_bot/templates/feature/formatters.py.tpl +15 -0
- codex_bot/templates/feature/handlers.py.tpl +32 -0
- codex_bot/templates/feature/handlers_redis.py.tpl +24 -0
- codex_bot/templates/feature/keyboards.py.tpl +14 -0
- codex_bot/templates/feature/orchestrator.py.tpl +26 -0
- codex_bot/templates/feature/orchestrator_redis.py.tpl +17 -0
- codex_bot/templates/feature/texts.py.tpl +6 -0
- codex_bot/templates/feature/ui.py.tpl +19 -0
- codex_bot/url_signer/__init__.py +7 -0
- codex_bot/url_signer/service.py +116 -0
- codex_bot-0.1.0.dist-info/METADATA +116 -0
- codex_bot-0.1.0.dist-info/RECORD +64 -0
- codex_bot-0.1.0.dist-info/WHEEL +4 -0
- codex_bot-0.1.0.dist-info/entry_points.txt +2 -0
- codex_bot-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FSMContextI18nManager — Locale manager via FSM storage.
|
|
3
|
+
|
|
4
|
+
Integrates with aiogram-i18n. Stores the user's selected language
|
|
5
|
+
in FSM (Redis) instead of a database, requiring no additional queries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from aiogram.fsm.context import FSMContext
|
|
11
|
+
from aiogram.types import User
|
|
12
|
+
|
|
13
|
+
from ...fsm.state_helper import StateHelper
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from aiogram_i18n.managers import BaseManager
|
|
17
|
+
except ImportError as e:
|
|
18
|
+
raise ImportError("FSMContextI18nManager requires 'aiogram-i18n'. Install it: pip install codex-bot[i18n]") from e
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FSMContextI18nManager(BaseManager):
|
|
22
|
+
"""
|
|
23
|
+
Language manager via FSM storage (Redis).
|
|
24
|
+
|
|
25
|
+
Locale determination priority:
|
|
26
|
+
1. FSM storage (key "locale") — user explicitly selected a language.
|
|
27
|
+
2. Telegram language_code — if it's in the allowed_locales list.
|
|
28
|
+
3. default_locale — fallback.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
allowed_locales: List of allowed language codes (e.g., ["ru", "en", "de"]).
|
|
32
|
+
default_locale: Default language if nothing matches.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
```python
|
|
36
|
+
from aiogram_i18n import I18nMiddleware
|
|
37
|
+
from aiogram_i18n.cores import FluentRuntimeCore
|
|
38
|
+
|
|
39
|
+
i18n = I18nMiddleware(
|
|
40
|
+
core=FluentRuntimeCore(path="locales/{locale}"),
|
|
41
|
+
manager=FSMContextI18nManager(allowed_locales=["ru", "en"], default_locale="en"),
|
|
42
|
+
default_locale="en",
|
|
43
|
+
)
|
|
44
|
+
i18n.setup(dp)
|
|
45
|
+
```
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
allowed_locales: list[str] | None = None,
|
|
51
|
+
default_locale: str = "en",
|
|
52
|
+
) -> None:
|
|
53
|
+
self.allowed_locales: list[str] = allowed_locales or []
|
|
54
|
+
self.default_locale: str = default_locale
|
|
55
|
+
|
|
56
|
+
async def get_locale(self, event_from_user: User | None = None, **kwargs: Any) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Determines the user's current locale.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
event_from_user: Telegram user.
|
|
62
|
+
**kwargs: aiogram-i18n context (includes "state").
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
String locale code (e.g., "ru").
|
|
66
|
+
"""
|
|
67
|
+
state: FSMContext | None = kwargs.get("state")
|
|
68
|
+
if state:
|
|
69
|
+
locale = await StateHelper.get_value(state, "locale")
|
|
70
|
+
if isinstance(locale, str):
|
|
71
|
+
return locale
|
|
72
|
+
|
|
73
|
+
if event_from_user:
|
|
74
|
+
lang = event_from_user.language_code
|
|
75
|
+
if lang and (not self.allowed_locales or lang in self.allowed_locales):
|
|
76
|
+
return lang
|
|
77
|
+
|
|
78
|
+
return str(self.default_locale)
|
|
79
|
+
|
|
80
|
+
async def set_locale(self, locale: str, **kwargs: Any) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Saves the selected locale to FSM.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
locale: Language code to save.
|
|
86
|
+
**kwargs: Context (includes "state").
|
|
87
|
+
"""
|
|
88
|
+
state: FSMContext | None = kwargs.get("state")
|
|
89
|
+
if state:
|
|
90
|
+
await StateHelper.update_value(state, "locale", locale)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ThrottlingMiddleware — Spam protection via atomic Redis rate limiting.
|
|
3
|
+
|
|
4
|
+
Uses the atomic SET NX (Set if Not eXists) operation instead of
|
|
5
|
+
two separate EXISTS + SET, which eliminates race conditions during
|
|
6
|
+
concurrent requests and halves the load on Redis.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from aiogram import BaseMiddleware
|
|
14
|
+
from aiogram.types import CallbackQuery, TelegramObject
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ThrottlingMiddleware(BaseMiddleware):
|
|
20
|
+
"""
|
|
21
|
+
Rate limiting middleware via atomic Redis SET NX.
|
|
22
|
+
|
|
23
|
+
One network request instead of two (EXISTS + SET).
|
|
24
|
+
Atomicity eliminates race conditions during parallel updates.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
redis: Async Redis client (``redis.asyncio.Redis``).
|
|
28
|
+
rate_limit: Minimum interval between requests in seconds.
|
|
29
|
+
Supports fractional values (e.g., ``0.5``).
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
```python
|
|
33
|
+
from redis.asyncio import Redis
|
|
34
|
+
from codex_bot.engine.middlewares import ThrottlingMiddleware
|
|
35
|
+
|
|
36
|
+
redis = Redis.from_url("redis://localhost")
|
|
37
|
+
builder.add_middleware(ThrottlingMiddleware(redis=redis, rate_limit=0.5))
|
|
38
|
+
```
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, redis: Any, rate_limit: float = 1.0) -> None:
|
|
42
|
+
self.redis = redis
|
|
43
|
+
self.rate_limit = rate_limit
|
|
44
|
+
|
|
45
|
+
async def __call__(
|
|
46
|
+
self,
|
|
47
|
+
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
|
48
|
+
event: TelegramObject,
|
|
49
|
+
data: dict[str, Any],
|
|
50
|
+
) -> Any:
|
|
51
|
+
user_id: int | None = event.from_user.id if hasattr(event, "from_user") and event.from_user else None
|
|
52
|
+
|
|
53
|
+
if not user_id:
|
|
54
|
+
return await handler(event, data)
|
|
55
|
+
|
|
56
|
+
key = f"throttle:{user_id}"
|
|
57
|
+
|
|
58
|
+
# Atomic operation: creates a key with TTL only if it didn't exist.
|
|
59
|
+
# Returns True on creation, None if the key already existed.
|
|
60
|
+
# px = milliseconds, supports fractional rate_limit (0.5 → 500 ms)
|
|
61
|
+
is_new = await self.redis.set(key, "1", px=int(self.rate_limit * 1000), nx=True)
|
|
62
|
+
|
|
63
|
+
if not is_new:
|
|
64
|
+
log.warning(f"Throttling | user={user_id} blocked")
|
|
65
|
+
if isinstance(event, CallbackQuery):
|
|
66
|
+
await event.answer("⏳ Not so fast!", show_alert=False)
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
return await handler(event, data)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UserValidationMiddleware — User presence check in an event.
|
|
3
|
+
|
|
4
|
+
Blocks processing of events without from_user (protection against system events,
|
|
5
|
+
channels without a subscriber, bots). Adds user to data for handlers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from aiogram import BaseMiddleware
|
|
13
|
+
from aiogram.types import CallbackQuery, Message, TelegramObject
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UserValidationMiddleware(BaseMiddleware):
|
|
19
|
+
"""
|
|
20
|
+
User validation middleware in an incoming event.
|
|
21
|
+
|
|
22
|
+
For Message and CallbackQuery:
|
|
23
|
+
- Blocks events without from_user (returns None).
|
|
24
|
+
- Adds `data["user"]` — an aiogram User object — for handlers.
|
|
25
|
+
|
|
26
|
+
For other event types (Update, etc.) — passes without checking,
|
|
27
|
+
allowing the bot to send messages to channels.
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
```python
|
|
31
|
+
dp.message.middleware(UserValidationMiddleware())
|
|
32
|
+
dp.callback_query.middleware(UserValidationMiddleware())
|
|
33
|
+
```
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
async def __call__(
|
|
37
|
+
self,
|
|
38
|
+
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
|
39
|
+
event: TelegramObject,
|
|
40
|
+
data: dict[str, Any],
|
|
41
|
+
) -> Any:
|
|
42
|
+
if isinstance(event, Message | CallbackQuery):
|
|
43
|
+
user = event.from_user
|
|
44
|
+
if not user:
|
|
45
|
+
log.warning(f"UserValidation | no from_user event_type={type(event).__name__}")
|
|
46
|
+
return None
|
|
47
|
+
data["user"] = user
|
|
48
|
+
|
|
49
|
+
return await handler(event, data)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_bot.engine.router_builder — Parametric assembly of the main router.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from codex_bot.engine.router_builder.router_builder import build_main_router, collect_feature_routers
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"collect_feature_routers",
|
|
9
|
+
"build_main_router",
|
|
10
|
+
]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RouterBuilder — Parametric assembly of the main Aiogram router.
|
|
3
|
+
|
|
4
|
+
Replaces hardcoded core/routers.py with explicit parameters
|
|
5
|
+
module_prefix and installed_features.
|
|
6
|
+
|
|
7
|
+
Fail Fast Principle: if a feature is declared in installed_features and its handlers.py
|
|
8
|
+
contains an error — the startup fails immediately, rather than silently ignoring the problem.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
from aiogram import Router
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def collect_feature_routers(
|
|
22
|
+
installed_features: list[str],
|
|
23
|
+
module_prefix: str = "",
|
|
24
|
+
handler_module: str = "handlers",
|
|
25
|
+
) -> list[Router]:
|
|
26
|
+
"""Collects Aiogram routers from feature modules.
|
|
27
|
+
|
|
28
|
+
For each feature in ``installed_features``, it attempts to import
|
|
29
|
+
``{module_prefix}.{feature_path}.{handler_module}``
|
|
30
|
+
and extract the ``router`` attribute from it.
|
|
31
|
+
|
|
32
|
+
**Fail Fast:** ``ImportError`` (no handlers file) — silent skip.
|
|
33
|
+
Any other error (syntax, runtime) — exception propagates up, the bot does not start.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
installed_features: List of paths to features
|
|
37
|
+
(e.g., ``["features.telegram.booking", "features.telegram.profile"]``).
|
|
38
|
+
module_prefix: Module prefix
|
|
39
|
+
(e.g., ``"myproject"`` → ``"myproject.features.telegram.booking.handlers"``).
|
|
40
|
+
Empty string — ``feature_path`` is used directly.
|
|
41
|
+
handler_module: Name of the module with the router (default is ``"handlers"``).
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
List of found ``Router`` objects.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
Exception: Any module loading error except ``ImportError`` (no file).
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
```python
|
|
51
|
+
routers = collect_feature_routers(
|
|
52
|
+
installed_features=["features.telegram.booking", "features.telegram.profile"],
|
|
53
|
+
module_prefix="myproject",
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
"""
|
|
57
|
+
routers: list[Router] = []
|
|
58
|
+
|
|
59
|
+
for feature_path in installed_features:
|
|
60
|
+
module_path = (
|
|
61
|
+
f"{module_prefix}.{feature_path}.{handler_module}" if module_prefix else f"{feature_path}.{handler_module}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
module = importlib.import_module(module_path)
|
|
66
|
+
feature_router = getattr(module, "router", None)
|
|
67
|
+
if feature_router and isinstance(feature_router, Router):
|
|
68
|
+
routers.append(feature_router)
|
|
69
|
+
log.info(f"RouterBuilder | feature='{feature_path}' status=loaded")
|
|
70
|
+
else:
|
|
71
|
+
log.debug(f"RouterBuilder | feature='{feature_path}' status=no_router_attr")
|
|
72
|
+
except ImportError as e:
|
|
73
|
+
if getattr(e, "name", None) == module_path:
|
|
74
|
+
# No handlers.py file — feature without UI, this is normal
|
|
75
|
+
log.debug(f"RouterBuilder | feature='{feature_path}' status=no_handlers_file")
|
|
76
|
+
else:
|
|
77
|
+
# handlers.py exists, but there's a broken import inside — fail
|
|
78
|
+
log.critical(f"RouterBuilder | Broken import inside '{feature_path}': {e}")
|
|
79
|
+
raise
|
|
80
|
+
# SyntaxError, NameError, etc. — not caught, the application fails immediately
|
|
81
|
+
|
|
82
|
+
return routers
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_main_router(
|
|
86
|
+
installed_features: list[str],
|
|
87
|
+
module_prefix: str = "",
|
|
88
|
+
handler_module: str = "handlers",
|
|
89
|
+
extra_routers: list[Router] | None = None,
|
|
90
|
+
router_name: str = "main_router",
|
|
91
|
+
) -> Router:
|
|
92
|
+
"""Assembles the main application router.
|
|
93
|
+
|
|
94
|
+
Creates a ``Router(name=router_name)``, includes routers from all features
|
|
95
|
+
and additional routers (e.g., ``common_fsm_router``).
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
installed_features: List of paths to features.
|
|
99
|
+
module_prefix: Module prefix (see ``collect_feature_routers``).
|
|
100
|
+
handler_module: Name of the module with the router (default is ``"handlers"``).
|
|
101
|
+
extra_routers: Additional routers included after features
|
|
102
|
+
(e.g., ``[common_fsm_router]``).
|
|
103
|
+
router_name: Name of the main router.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The assembled ``Router``.
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
```python
|
|
110
|
+
from codex_bot.engine.router_builder import build_main_router
|
|
111
|
+
from codex_bot.fsm import common_fsm_router
|
|
112
|
+
|
|
113
|
+
main_router = build_main_router(
|
|
114
|
+
installed_features=settings.INSTALLED_FEATURES,
|
|
115
|
+
module_prefix="myproject",
|
|
116
|
+
extra_routers=[common_fsm_router],
|
|
117
|
+
)
|
|
118
|
+
dp.include_router(main_router)
|
|
119
|
+
```
|
|
120
|
+
"""
|
|
121
|
+
main_router = Router(name=router_name)
|
|
122
|
+
feature_routers = collect_feature_routers(
|
|
123
|
+
installed_features=installed_features,
|
|
124
|
+
module_prefix=module_prefix,
|
|
125
|
+
handler_module=handler_module,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
all_routers: list[Router] = feature_routers + (extra_routers or [])
|
|
129
|
+
if all_routers:
|
|
130
|
+
main_router.include_routers(*all_routers)
|
|
131
|
+
|
|
132
|
+
log.info(f"RouterBuilder | UI features loaded={len(feature_routers)} extra={len(extra_routers or [])}")
|
|
133
|
+
return main_router
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_bot.fsm — FSM manager, garbage collector, and common handlers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .common_fsm_handlers import common_fsm_router
|
|
6
|
+
from .garbage_collector import GarbageStateRegistry, IsGarbageStateFilter
|
|
7
|
+
from .state_helper import StateHelper
|
|
8
|
+
from .state_manager import BaseStateManager
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"BaseStateManager",
|
|
12
|
+
"StateHelper",
|
|
13
|
+
"GarbageStateRegistry",
|
|
14
|
+
"IsGarbageStateFilter",
|
|
15
|
+
"common_fsm_router",
|
|
16
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
common_fsm_router — Common FSM handlers for the entire bot.
|
|
3
|
+
|
|
4
|
+
Connected to the main router via build_main_router() or manually:
|
|
5
|
+
dp.include_router(common_fsm_router)
|
|
6
|
+
|
|
7
|
+
Current set of handlers:
|
|
8
|
+
- Garbage Collector: deletes text messages in "garbage" states.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
from aiogram import F, Router
|
|
14
|
+
from aiogram.exceptions import TelegramAPIError
|
|
15
|
+
from aiogram.fsm.context import FSMContext
|
|
16
|
+
from aiogram.types import Message
|
|
17
|
+
|
|
18
|
+
from .garbage_collector import IsGarbageStateFilter
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
common_fsm_router = Router(name="codex_bot:common_fsm_router")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@common_fsm_router.message(F.text, IsGarbageStateFilter())
|
|
26
|
+
async def delete_garbage_text(message: Message, state: FSMContext) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Deletes unwanted text messages in "garbage" states.
|
|
29
|
+
|
|
30
|
+
Triggers only if the user's current FSM state is registered
|
|
31
|
+
in GarbageStateRegistry. States are added dynamically as features load.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
message: Incoming text message.
|
|
35
|
+
state: User's FSM context.
|
|
36
|
+
"""
|
|
37
|
+
user_id = message.from_user.id if message.from_user else "N/A"
|
|
38
|
+
current_state = await state.get_state()
|
|
39
|
+
log.info(f"GarbageCollector | user={user_id} state={current_state}")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
await message.delete()
|
|
43
|
+
text_preview = (message.text or "")[:20]
|
|
44
|
+
log.debug(f"GarbageCollector | status=deleted user={user_id} text='{text_preview}'")
|
|
45
|
+
except TelegramAPIError as e:
|
|
46
|
+
log.warning(f"GarbageCollector | status=delete_failed user={user_id} error='{e}'")
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GarbageStateRegistry and IsGarbageStateFilter — Automatic deletion
|
|
3
|
+
of text messages in states where only button clicks are expected.
|
|
4
|
+
|
|
5
|
+
The registry is filled dynamically when features are loaded via
|
|
6
|
+
FeatureDiscoveryService or directly via GarbageStateRegistry.register().
|
|
7
|
+
This allows any feature to declare its states as "garbage" without
|
|
8
|
+
touching the global configuration.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
```python
|
|
12
|
+
# In feature_setting.py of a feature:
|
|
13
|
+
from aiogram.fsm.state import State, StatesGroup
|
|
14
|
+
|
|
15
|
+
class BookingStates(StatesGroup):
|
|
16
|
+
select_date = State()
|
|
17
|
+
select_time = State()
|
|
18
|
+
|
|
19
|
+
# Register the entire group as garbage:
|
|
20
|
+
GarbageStateRegistry.register(BookingStates)
|
|
21
|
+
|
|
22
|
+
# Or a single state:
|
|
23
|
+
GarbageStateRegistry.register(BookingStates.select_date)
|
|
24
|
+
```
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from aiogram.filters import Filter
|
|
30
|
+
from aiogram.fsm.context import FSMContext
|
|
31
|
+
from aiogram.fsm.state import State, StatesGroup
|
|
32
|
+
from aiogram.types import Message
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GarbageStateRegistry:
|
|
36
|
+
"""
|
|
37
|
+
Registry of FSM states where text messages are considered garbage.
|
|
38
|
+
|
|
39
|
+
Global registry: states are registered once at startup,
|
|
40
|
+
the filter checks the user's current state with each message.
|
|
41
|
+
|
|
42
|
+
Supports registration of:
|
|
43
|
+
- a single `State` object
|
|
44
|
+
- an entire `StatesGroup` (all states in the group)
|
|
45
|
+
- a string (state name)
|
|
46
|
+
- a list / tuple / set of the above types
|
|
47
|
+
|
|
48
|
+
Example:
|
|
49
|
+
```python
|
|
50
|
+
GarbageStateRegistry.register(MyFeatureStates) # entire group
|
|
51
|
+
GarbageStateRegistry.register(MyFeatureStates.waiting) # single state
|
|
52
|
+
GarbageStateRegistry.register(["Feature1:main", "Feature2:step1"])
|
|
53
|
+
```
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
_states: set[str] = set()
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def register(
|
|
60
|
+
cls,
|
|
61
|
+
state: State | StatesGroup | type[StatesGroup] | str | list[Any] | tuple[Any, ...] | set[Any],
|
|
62
|
+
) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Registers state(s) as garbage.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
state: State, StatesGroup, string, or an iterable of them.
|
|
68
|
+
"""
|
|
69
|
+
if isinstance(state, list | tuple | set):
|
|
70
|
+
for s in state:
|
|
71
|
+
cls.register(s)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
# StatesGroup class (not instance) — take all its states
|
|
75
|
+
if isinstance(state, type) and issubclass(state, StatesGroup):
|
|
76
|
+
for s in state.__all_states__:
|
|
77
|
+
cls.register(s)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# StatesGroup instance — take __all_states__ (Aiogram 3)
|
|
81
|
+
if hasattr(state, "__all_states__"):
|
|
82
|
+
for s in state.__all_states__:
|
|
83
|
+
cls.register(s)
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
# State object or string
|
|
87
|
+
state_name = state.state if isinstance(state, State) else str(state)
|
|
88
|
+
if state_name:
|
|
89
|
+
cls._states.add(state_name)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def is_garbage(cls, state_name: str | None) -> bool:
|
|
93
|
+
"""
|
|
94
|
+
Checks if a state is registered as garbage.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
state_name: String name of the current FSM state.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
True if the state is registered as garbage.
|
|
101
|
+
"""
|
|
102
|
+
if state_name is None:
|
|
103
|
+
return False
|
|
104
|
+
return state_name in cls._states
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def registered_states(cls) -> frozenset[str]:
|
|
108
|
+
"""
|
|
109
|
+
Returns all registered garbage states (read-only).
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Frozenset of string state names.
|
|
113
|
+
"""
|
|
114
|
+
return frozenset(cls._states)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class IsGarbageStateFilter(Filter):
|
|
118
|
+
"""
|
|
119
|
+
Aiogram filter: True if the user's current FSM state is garbage.
|
|
120
|
+
|
|
121
|
+
Used in common_fsm_router for automatic deletion of unwanted text messages.
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
```python
|
|
125
|
+
@router.message(F.text, IsGarbageStateFilter())
|
|
126
|
+
async def delete_garbage(message: Message, state: FSMContext):
|
|
127
|
+
await message.delete()
|
|
128
|
+
```
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
async def __call__(self, message: Message, state: FSMContext) -> bool:
|
|
132
|
+
"""
|
|
133
|
+
Args:
|
|
134
|
+
message: Incoming message.
|
|
135
|
+
state: User's FSM context.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
True if the current state is registered as garbage.
|
|
139
|
+
"""
|
|
140
|
+
current_state = await state.get_state()
|
|
141
|
+
return GarbageStateRegistry.is_garbage(current_state)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
StateHelper — Universal utilities for safe FSMContext data manipulation.
|
|
3
|
+
|
|
4
|
+
Provides atomic-like operations for getting, updating, and removing keys
|
|
5
|
+
from aiogram FSM storage, preventing "zombie dictionaries" in Redis.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from aiogram.fsm.context import FSMContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StateHelper:
|
|
14
|
+
"""
|
|
15
|
+
Helper for safe operations with FSMContext data.
|
|
16
|
+
|
|
17
|
+
Encapsulates the logic of reading and writing to FSM storage
|
|
18
|
+
to ensure consistency across different modules (StateManager, I18n, etc.).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
async def get_value(state: FSMContext, key: str, default: Any = None) -> Any:
|
|
23
|
+
"""
|
|
24
|
+
Safely retrieves a value from FSM storage.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
state: FSM context.
|
|
28
|
+
key: Storage key.
|
|
29
|
+
default: Default value if key is missing.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Stored value or default.
|
|
33
|
+
"""
|
|
34
|
+
data = await state.get_data()
|
|
35
|
+
return data.get(key, default)
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
async def update_value(state: FSMContext, key: str, value: Any) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Updates a value in FSM storage or removes the key if value is empty.
|
|
41
|
+
|
|
42
|
+
If the value is None, an empty dict, or an empty list, the key is
|
|
43
|
+
completely removed from the storage to avoid "zombie" entries in Redis.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
state: FSM context.
|
|
47
|
+
key: Storage key.
|
|
48
|
+
value: Value to store.
|
|
49
|
+
"""
|
|
50
|
+
data = await state.get_data()
|
|
51
|
+
|
|
52
|
+
# Check for "empty" values to trigger deletion
|
|
53
|
+
is_empty = value is None or (isinstance(value, dict | list) and not value)
|
|
54
|
+
|
|
55
|
+
if is_empty:
|
|
56
|
+
if key in data:
|
|
57
|
+
del data[key]
|
|
58
|
+
await state.set_data(data)
|
|
59
|
+
else:
|
|
60
|
+
data[key] = value
|
|
61
|
+
await state.set_data(data)
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
async def clear_key(state: FSMContext, key: str) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Completely removes a key from FSM storage.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
state: FSM context.
|
|
70
|
+
key: Key to remove.
|
|
71
|
+
"""
|
|
72
|
+
await StateHelper.update_value(state, key, None)
|