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.
Files changed (64) hide show
  1. codex_bot/__init__.py +24 -0
  2. codex_bot/animation/__init__.py +10 -0
  3. codex_bot/animation/animation_service.py +298 -0
  4. codex_bot/base/__init__.py +22 -0
  5. codex_bot/base/base_orchestrator.py +123 -0
  6. codex_bot/base/context_dto.py +42 -0
  7. codex_bot/base/view_dto.py +93 -0
  8. codex_bot/cli/__init__.py +11 -0
  9. codex_bot/cli/commands.py +215 -0
  10. codex_bot/director/__init__.py +13 -0
  11. codex_bot/director/director.py +107 -0
  12. codex_bot/director/protocols.py +80 -0
  13. codex_bot/engine/__init__.py +13 -0
  14. codex_bot/engine/discovery/__init__.py +7 -0
  15. codex_bot/engine/discovery/service.py +258 -0
  16. codex_bot/engine/factory/__init__.py +7 -0
  17. codex_bot/engine/factory/bot_builder.py +111 -0
  18. codex_bot/engine/http/__init__.py +10 -0
  19. codex_bot/engine/http/api_client.py +123 -0
  20. codex_bot/engine/i18n/__init__.py +7 -0
  21. codex_bot/engine/i18n/locales_compiler.py +83 -0
  22. codex_bot/engine/middlewares/__init__.py +24 -0
  23. codex_bot/engine/middlewares/container.py +46 -0
  24. codex_bot/engine/middlewares/i18n.py +90 -0
  25. codex_bot/engine/middlewares/throttling.py +69 -0
  26. codex_bot/engine/middlewares/user_validation.py +49 -0
  27. codex_bot/engine/router_builder/__init__.py +10 -0
  28. codex_bot/engine/router_builder/router_builder.py +133 -0
  29. codex_bot/fsm/__init__.py +16 -0
  30. codex_bot/fsm/common_fsm_handlers.py +46 -0
  31. codex_bot/fsm/garbage_collector.py +141 -0
  32. codex_bot/fsm/state_helper.py +72 -0
  33. codex_bot/fsm/state_manager.py +104 -0
  34. codex_bot/helper/__init__.py +7 -0
  35. codex_bot/helper/context_helper.py +61 -0
  36. codex_bot/redis/__init__.py +15 -0
  37. codex_bot/redis/dispatcher.py +169 -0
  38. codex_bot/redis/router.py +71 -0
  39. codex_bot/redis/stream_processor.py +210 -0
  40. codex_bot/sender/__init__.py +15 -0
  41. codex_bot/sender/protocols.py +67 -0
  42. codex_bot/sender/sender_keys.py +50 -0
  43. codex_bot/sender/sender_manager.py +85 -0
  44. codex_bot/sender/view_sender.py +200 -0
  45. codex_bot/templates/feature/callbacks.py.tpl +14 -0
  46. codex_bot/templates/feature/contract.py.tpl +12 -0
  47. codex_bot/templates/feature/dto.py.tpl +7 -0
  48. codex_bot/templates/feature/feature.py.tpl +26 -0
  49. codex_bot/templates/feature/feature_redis.py.tpl +6 -0
  50. codex_bot/templates/feature/formatters.py.tpl +15 -0
  51. codex_bot/templates/feature/handlers.py.tpl +32 -0
  52. codex_bot/templates/feature/handlers_redis.py.tpl +24 -0
  53. codex_bot/templates/feature/keyboards.py.tpl +14 -0
  54. codex_bot/templates/feature/orchestrator.py.tpl +26 -0
  55. codex_bot/templates/feature/orchestrator_redis.py.tpl +17 -0
  56. codex_bot/templates/feature/texts.py.tpl +6 -0
  57. codex_bot/templates/feature/ui.py.tpl +19 -0
  58. codex_bot/url_signer/__init__.py +7 -0
  59. codex_bot/url_signer/service.py +116 -0
  60. codex_bot-0.1.0.dist-info/METADATA +116 -0
  61. codex_bot-0.1.0.dist-info/RECORD +64 -0
  62. codex_bot-0.1.0.dist-info/WHEEL +4 -0
  63. codex_bot-0.1.0.dist-info/entry_points.txt +2 -0
  64. 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)