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,258 @@
1
+ """
2
+ FeatureDiscoveryService — Auto-discovery and registration of features.
3
+
4
+ Supports two modes of operation:
5
+ 1. **Auto-discovery** — scans modules from a list of feature_paths via importlib.
6
+ 2. **Explicit registration** — the developer passes objects directly (fallback for
7
+ environments without dynamic imports: PyInstaller, strict mypy, etc.).
8
+
9
+ Both modes can be combined in a single application.
10
+ """
11
+
12
+ import importlib
13
+ import logging
14
+ from types import ModuleType
15
+ from typing import Any, cast
16
+
17
+ from aiogram import Router
18
+
19
+ from ...fsm.garbage_collector import GarbageStateRegistry
20
+ from ...redis.dispatcher import BotRedisDispatcher
21
+ from ...redis.router import RedisRouter
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+
26
+ class FeatureDiscoveryService:
27
+ """
28
+ Service for discovering and registering feature configurations.
29
+
30
+ Two modes:
31
+
32
+ **Mode 1: Auto-discovery** (similar to Django's INSTALLED_APPS):
33
+ ```python
34
+ discovery = FeatureDiscoveryService(
35
+ module_prefix="src.telegram_bot",
36
+ installed_features=["features.telegram.commands", "features.telegram.booking"],
37
+ installed_redis_features=["features.redis.notifications"],
38
+ redis_dispatcher=bot_redis_dispatcher,
39
+ )
40
+ discovery.discover_all()
41
+ orchestrators = discovery.create_feature_orchestrators(container)
42
+ ```
43
+
44
+ **Mode 2: Explicit registration** (fallback):
45
+ ```python
46
+ discovery = FeatureDiscoveryService()
47
+ discovery.register_router(commands_router)
48
+ discovery.register_orchestrator("booking", BookingOrchestrator(container))
49
+ discovery.register_garbage_states(BookingStates)
50
+ ```
51
+
52
+ Args:
53
+ module_prefix: Module path prefix for auto-discovery.
54
+ Example: "src.telegram_bot".
55
+ installed_features: List of Telegram feature paths (with Aiogram routers).
56
+ installed_redis_features: List of Redis feature paths (with RedisRouter).
57
+ redis_dispatcher: Dispatcher for registering Redis handlers.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ module_prefix: str = "",
63
+ installed_features: list[str] | None = None,
64
+ installed_redis_features: list[str] | None = None,
65
+ redis_dispatcher: BotRedisDispatcher | None = None,
66
+ ) -> None:
67
+ self._prefix = module_prefix
68
+ self._features = installed_features or []
69
+ self._redis_features = installed_redis_features or []
70
+ self._redis_dispatcher = redis_dispatcher
71
+
72
+ # Explicitly registered objects (Mode 2)
73
+ self._explicit_routers: list[Router] = []
74
+ self._explicit_orchestrators: dict[str, Any] = {}
75
+
76
+ # =========================================================================
77
+ # Mode 1: Auto-discovery
78
+ # =========================================================================
79
+
80
+ def discover_all(self) -> None:
81
+ """
82
+ Starts auto-discovery of all registered features.
83
+
84
+ For Telegram features: loads menu config, garbage states, Aiogram routers.
85
+ For Redis features: loads RedisRouter and connects it to the dispatcher.
86
+ """
87
+ for feature_path in self._features:
88
+ self._discover_menu(feature_path)
89
+ self._discover_garbage_states(feature_path)
90
+
91
+ for feature_path in self._redis_features:
92
+ self._discover_redis_handlers(feature_path)
93
+ self._discover_garbage_states(feature_path)
94
+
95
+ def create_feature_orchestrators(self, container: Any) -> dict[str, Any]:
96
+ """
97
+ Creates orchestrators for all features via their factory functions.
98
+
99
+ Looks for the `create_orchestrator(container)` function in the `feature_setting.py`
100
+ of each feature. The key prefix for Redis features is "redis_".
101
+
102
+ Args:
103
+ container: Project's DI container.
104
+
105
+ Returns:
106
+ Dictionary {feature_key: orchestrator_instance}.
107
+ """
108
+ orchestrators = dict(self._explicit_orchestrators)
109
+ configs = [(self._features, ""), (self._redis_features, "redis_")]
110
+
111
+ for feature_list, prefix in configs:
112
+ for feature_path in feature_list:
113
+ module = self._load_feature_module(feature_path)
114
+ if not module:
115
+ continue
116
+ factory = getattr(module, "create_orchestrator", None)
117
+ if not factory:
118
+ continue
119
+ base_name = feature_path.split(".")[-1]
120
+ key = f"{prefix}{base_name}"
121
+ orchestrators[key] = factory(container)
122
+ log.info(f"FeatureDiscovery | orchestrator loaded key='{key}'")
123
+
124
+ return orchestrators
125
+
126
+ def collect_aiogram_routers(self) -> list[Router]:
127
+ """
128
+ Collects Aiogram Routers from all Telegram features.
129
+
130
+ Returns:
131
+ List of Routers to be included in the main router.
132
+ """
133
+ routers = list(self._explicit_routers)
134
+
135
+ for feature_path in self._features:
136
+ module_path = f"{self._prefix}.{feature_path}.handlers" if self._prefix else f"{feature_path}.handlers"
137
+ try:
138
+ module = importlib.import_module(module_path)
139
+ router = getattr(module, "router", None)
140
+ if router and isinstance(router, Router):
141
+ routers.append(router)
142
+ log.info(f"FeatureDiscovery | router loaded feature='{feature_path}'")
143
+ except ImportError as e:
144
+ if getattr(e, "name", None) == module_path:
145
+ log.debug(f"FeatureDiscovery | no handlers file feature='{feature_path}'")
146
+ else:
147
+ log.critical(f"FeatureDiscovery | Broken import inside '{feature_path}': {e}")
148
+ raise
149
+
150
+ return routers
151
+
152
+ def get_menu_buttons(self, is_admin: bool | None = None) -> dict[str, dict[str, Any]]:
153
+ """
154
+ Returns menu button configurations for all Telegram features.
155
+
156
+ Args:
157
+ is_admin: None — all buttons, True — only admin, False — only user.
158
+
159
+ Returns:
160
+ Dictionary {feature_key: menu_config_dict}.
161
+ """
162
+ buttons: dict[str, dict[str, Any]] = {}
163
+ for feature_path in self._features:
164
+ btn = self._discover_menu(feature_path)
165
+ if btn:
166
+ if is_admin is not None and btn.get("is_admin", False) != is_admin:
167
+ continue
168
+ key = btn.get("key", feature_path)
169
+ buttons[key] = btn
170
+ return buttons
171
+
172
+ # =========================================================================
173
+ # Mode 2: Explicit registration (fallback)
174
+ # =========================================================================
175
+
176
+ def register_router(self, router: Router) -> None:
177
+ """
178
+ Explicitly registers an Aiogram Router (without auto-discovery).
179
+
180
+ Args:
181
+ router: Instance of aiogram.Router.
182
+ """
183
+ self._explicit_routers.append(router)
184
+
185
+ def register_orchestrator(self, key: str, orchestrator: Any) -> None:
186
+ """
187
+ Explicitly registers a feature orchestrator.
188
+
189
+ Args:
190
+ key: Feature key (e.g., "booking", "redis_notifications").
191
+ orchestrator: Orchestrator instance.
192
+ """
193
+ self._explicit_orchestrators[key] = orchestrator
194
+
195
+ def register_garbage_states(self, states: Any) -> None:
196
+ """
197
+ Explicitly registers states as garbage (Garbage Collector).
198
+
199
+ Args:
200
+ states: State, StatesGroup, string, or a list of them.
201
+ """
202
+ GarbageStateRegistry.register(states)
203
+
204
+ # =========================================================================
205
+ # Private auto-discovery methods
206
+ # =========================================================================
207
+
208
+ def _load_feature_module(self, feature_path: str) -> ModuleType | None:
209
+ candidates = []
210
+ if self._prefix:
211
+ candidates.append(f"{self._prefix}.{feature_path}.feature_setting")
212
+ candidates.append(f"{self._prefix}.{feature_path}")
213
+ candidates.append(f"{feature_path}.feature_setting")
214
+ candidates.append(feature_path)
215
+
216
+ for path in candidates:
217
+ try:
218
+ return importlib.import_module(path)
219
+ except ImportError:
220
+ continue
221
+ return None
222
+
223
+ def _discover_menu(self, feature_path: str) -> dict[str, Any] | None:
224
+ module = self._load_feature_module(feature_path)
225
+ if module:
226
+ config = getattr(module, "MENU_CONFIG", None)
227
+ if config and isinstance(config, dict):
228
+ return cast(dict[str, Any] | None, config)
229
+ return None
230
+
231
+ def _discover_garbage_states(self, feature_path: str) -> None:
232
+ module = self._load_feature_module(feature_path)
233
+ if not module:
234
+ return
235
+ garbage = getattr(module, "GARBAGE_STATES", None)
236
+ if garbage:
237
+ GarbageStateRegistry.register(garbage)
238
+ return
239
+ if getattr(module, "GARBAGE_COLLECT", False):
240
+ states = getattr(module, "STATES", None)
241
+ if states:
242
+ GarbageStateRegistry.register(states)
243
+
244
+ def _discover_redis_handlers(self, feature_path: str) -> None:
245
+ if not self._redis_dispatcher:
246
+ return
247
+ module_path = f"{self._prefix}.{feature_path}.handlers" if self._prefix else f"{feature_path}.handlers"
248
+ try:
249
+ module = importlib.import_module(module_path)
250
+ redis_router = getattr(module, "redis_router", None)
251
+ if redis_router and isinstance(redis_router, RedisRouter):
252
+ self._redis_dispatcher.include_router(redis_router)
253
+ except ImportError as e:
254
+ if getattr(e, "name", None) == module_path:
255
+ log.debug(f"FeatureDiscovery | no redis handlers file feature='{module_path}'")
256
+ else:
257
+ log.critical(f"FeatureDiscovery | Broken import inside '{module_path}': {e}")
258
+ raise
@@ -0,0 +1,7 @@
1
+ """
2
+ codex_bot.engine.factory — BotBuilder for creating Bot + Dispatcher.
3
+ """
4
+
5
+ from codex_bot.engine.factory.bot_builder import BotBuilder
6
+
7
+ __all__ = ["BotBuilder"]
@@ -0,0 +1,111 @@
1
+ """
2
+ BotBuilder — Builder pattern for creating Bot + Dispatcher.
3
+
4
+ Allows explicit control over the order of middleware connection.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ from aiogram import Bot, Dispatcher
13
+ from aiogram.client.default import DefaultBotProperties
14
+ from aiogram.fsm.storage.base import BaseStorage
15
+ from aiogram.fsm.storage.memory import MemoryStorage
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ class BotBuilder:
21
+ """Builder for Bot + Dispatcher with explicit middleware management.
22
+
23
+ Allows injecting custom middleware at arbitrary points,
24
+ unlike the functional approach with a fixed order.
25
+
26
+ Args:
27
+ bot_token: Telegram bot token.
28
+ parse_mode: Message parsing mode (default is ``"HTML"``).
29
+ fsm_storage: FSM storage. ``None`` → ``MemoryStorage``.
30
+ dispatcher_kwargs: Additional kwargs for ``Dispatcher``.
31
+
32
+ Raises:
33
+ ValueError: If ``bot_token`` is empty.
34
+
35
+ Example:
36
+ ```python
37
+ from codex_bot.engine.factory import BotBuilder
38
+ from codex_bot.engine.middlewares import UserValidationMiddleware, ThrottlingMiddleware
39
+
40
+ builder = BotBuilder(bot_token=settings.bot_token, fsm_storage=RedisStorage(redis))
41
+ builder.add_middleware(UserValidationMiddleware())
42
+ builder.add_middleware(ThrottlingMiddleware(redis=redis_client))
43
+ builder.add_middleware(MyAnalyticsMiddleware())
44
+ bot, dp = builder.build()
45
+ ```
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ bot_token: str,
51
+ parse_mode: str = "HTML",
52
+ fsm_storage: BaseStorage | None = None,
53
+ dispatcher_kwargs: dict[str, Any] | None = None,
54
+ ) -> None:
55
+ if not bot_token:
56
+ raise ValueError("bot_token must not be empty")
57
+
58
+ self._bot_token = bot_token
59
+ self._parse_mode = parse_mode
60
+ self._fsm_storage: BaseStorage = fsm_storage or MemoryStorage()
61
+ self._dispatcher_kwargs: dict[str, Any] = dispatcher_kwargs or {}
62
+ self._middlewares: list[Any] = []
63
+
64
+ def add_middleware(self, middleware: Any) -> BotBuilder:
65
+ """Adds middleware to the connection queue.
66
+
67
+ Middleware are connected in the order they are added.
68
+
69
+ Args:
70
+ middleware: Middleware instance to connect to ``dp.update``.
71
+
72
+ Returns:
73
+ ``self`` for chaining.
74
+
75
+ Example:
76
+ ```python
77
+ builder.add_middleware(UserValidationMiddleware())
78
+ .add_middleware(ThrottlingMiddleware(redis))
79
+ ```
80
+ """
81
+ self._middlewares.append(middleware)
82
+ return self
83
+
84
+ def build(self) -> tuple[Bot, Dispatcher]:
85
+ """Assembles Bot and Dispatcher.
86
+
87
+ Creates a ``Bot`` with the specified parameters and a ``Dispatcher`` with FSM storage.
88
+ Connects all middleware via ``dp.update.middleware``.
89
+
90
+ Returns:
91
+ Tuple ``(bot, dispatcher)``.
92
+
93
+ Example:
94
+ ```python
95
+ bot, dp = builder.build()
96
+ await dp.start_polling(bot)
97
+ ```
98
+ """
99
+ bot = Bot(
100
+ token=self._bot_token,
101
+ default=DefaultBotProperties(parse_mode=self._parse_mode),
102
+ )
103
+
104
+ dp = Dispatcher(storage=self._fsm_storage, **self._dispatcher_kwargs)
105
+
106
+ for middleware in self._middlewares:
107
+ dp.update.middleware(middleware)
108
+ log.debug(f"BotBuilder | Middleware registered: {middleware.__class__.__name__}")
109
+
110
+ log.info(f"BotBuilder | Built bot with {len(self._middlewares)} middleware(s)")
111
+ return bot, dp
@@ -0,0 +1,10 @@
1
+ """
2
+ codex_bot.engine.http — Base async HTTP client.
3
+ """
4
+
5
+ from codex_bot.engine.http.api_client import ApiClientError, BaseApiClient
6
+
7
+ __all__ = [
8
+ "BaseApiClient",
9
+ "ApiClientError",
10
+ ]
@@ -0,0 +1,123 @@
1
+ """
2
+ BaseApiClient — Long-lived async HTTP client on top of httpx.
3
+
4
+ The client is created ONCE (in the DI container at startup) and reuses
5
+ the TCP connection pool throughout the application's lifetime. This eliminates
6
+ the overhead of TLS handshakes for each request.
7
+
8
+ Inherit to create clients for specific backend APIs.
9
+ """
10
+
11
+ import logging
12
+ from typing import Any
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ try:
17
+ import httpx
18
+ except ImportError as e:
19
+ raise ImportError("BaseApiClient requires 'httpx'. Install it: pip install codex-bot[http]") from e
20
+
21
+
22
+ class ApiClientError(Exception):
23
+ """Base HTTP client error."""
24
+
25
+
26
+ class BaseApiClient:
27
+ """
28
+ Base async HTTP client with a long-lived connection pool.
29
+
30
+ Created once in the DI container, not per-request.
31
+ Reuses TCP connections via httpx connection pooling.
32
+ Call ``close()`` when stopping the bot for proper termination.
33
+
34
+ Args:
35
+ base_url: Base API URL (e.g., ``"https://api.example.com"``).
36
+ api_key: API key for the ``X-API-Key`` header. ``None`` — no authentication.
37
+ timeout: Total request timeout in seconds (``connect`` is always 5 sec).
38
+
39
+ Example:
40
+ ```python
41
+ class BookingApiClient(BaseApiClient):
42
+ async def get_slots(self, date: str) -> list[dict]:
43
+ return await self._request("GET", "/slots", params={"date": date})
44
+
45
+ # In the DI container — once:
46
+ client = BookingApiClient(base_url="https://api.example.com", api_key="secret")
47
+
48
+ # When stopping the bot:
49
+ await client.close()
50
+ ```
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ base_url: str,
56
+ api_key: str | None = None,
57
+ timeout: float = 10.0,
58
+ ) -> None:
59
+ headers: dict[str, str] = {
60
+ "Content-Type": "application/json",
61
+ "Accept": "application/json",
62
+ }
63
+ if api_key:
64
+ headers["X-API-Key"] = api_key
65
+
66
+ # Long-lived client — created once, lives for the entire bot duration
67
+ self.client = httpx.AsyncClient(
68
+ base_url=base_url.rstrip("/"),
69
+ headers=headers,
70
+ timeout=httpx.Timeout(timeout, connect=5.0),
71
+ )
72
+
73
+ async def close(self) -> None:
74
+ """Closes the connection pool. Call when stopping the bot.
75
+
76
+ Example:
77
+ ```python
78
+ # In on_shutdown hook:
79
+ await api_client.close()
80
+ ```
81
+ """
82
+ await self.client.aclose()
83
+
84
+ async def _request(
85
+ self,
86
+ method: str,
87
+ endpoint: str,
88
+ json: dict[str, Any] | None = None,
89
+ params: dict[str, Any] | None = None,
90
+ ) -> Any:
91
+ """Performs an HTTP request via the long-lived client.
92
+
93
+ Args:
94
+ method: HTTP method (``"GET"``, ``"POST"``, ``"PUT"``, ``"DELETE"``).
95
+ endpoint: Endpoint path (e.g., ``"/api/v1/slots"``).
96
+ json: Request body in JSON format.
97
+ params: Query parameters.
98
+
99
+ Returns:
100
+ Parsed JSON response or ``None`` for 204 No Content.
101
+
102
+ Raises:
103
+ ApiClientError: For HTTP errors or connection issues.
104
+ """
105
+ url = endpoint.lstrip("/")
106
+
107
+ try:
108
+ log.debug(f"API {method} /{url} | params={params}")
109
+ response = await self.client.request(method=method, url=url, json=json, params=params)
110
+ response.raise_for_status()
111
+
112
+ # 204 No Content and empty responses (DELETE, some POST)
113
+ if response.status_code == 204 or not response.content:
114
+ return None
115
+
116
+ return response.json()
117
+
118
+ except httpx.HTTPStatusError as e:
119
+ log.error(f"API HTTP error {e.response.status_code}: {e.response.text}")
120
+ raise ApiClientError(f"HTTP {e.response.status_code}") from e
121
+ except httpx.RequestError as e:
122
+ log.error(f"API connection error: {e}")
123
+ raise ApiClientError(f"Connection error: {e}") from e
@@ -0,0 +1,7 @@
1
+ """
2
+ codex_bot.engine.i18n — Compilation of Fluent (.ftl) locales.
3
+ """
4
+
5
+ from codex_bot.engine.i18n.locales_compiler import compile_locales
6
+
7
+ __all__ = ["compile_locales"]
@@ -0,0 +1,83 @@
1
+ """
2
+ LocalesCompiler — Compilation of Fluent (.ftl) locales into a single tmp directory.
3
+
4
+ Collects all .ftl files from language subfolders and merges them
5
+ into one messages.ftl per language for passing to FluentRuntimeCore.
6
+
7
+ Each project gets an isolated tmp folder based on the hash of the
8
+ absolute path to the locales — multiple bots on one server
9
+ do not overwrite each other's files.
10
+ """
11
+
12
+ import hashlib
13
+ import logging
14
+ import pathlib
15
+ import shutil
16
+ import tempfile
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ def compile_locales(base_path: pathlib.Path) -> str:
22
+ """Compiles .ftl files from language subfolders into an isolated tmp directory.
23
+
24
+ Collects all ``*.ftl`` files from ``{base_path}/{lang}/*.ftl``
25
+ into ``/tmp/bot_locales_{hash}/{lang}/messages.ftl``.
26
+ The resulting path is passed to ``FluentRuntimeCore``.
27
+
28
+ The folder is isolated by the hash of the absolute path: multiple bots or
29
+ parallel tests on the same server do not interfere with each other.
30
+
31
+ Args:
32
+ base_path: Path to the directory with language subfolders.
33
+
34
+ Returns:
35
+ Path template ``"/tmp/bot_locales_{hash}/{locale}"`` for ``FluentRuntimeCore``.
36
+
37
+ Raises:
38
+ OSError: If the tmp directory cannot be created or files cannot be written.
39
+
40
+ Example:
41
+ ```python
42
+ from codex_bot.engine.i18n import compile_locales
43
+
44
+ locales_path = compile_locales(Path("resources/locales"))
45
+ core = FluentRuntimeCore(path=locales_path)
46
+ ```
47
+ """
48
+ # Short hash of the absolute path — unique per project, stable between restarts
49
+ # usedforsecurity=False tells Bandit/linters that this is not a cryptographic hash.
50
+ path_hash = hashlib.md5(str(base_path.absolute()).encode(), usedforsecurity=False).hexdigest()[:8]
51
+ tmp_dir = pathlib.Path(tempfile.gettempdir()) / f"bot_locales_{path_hash}"
52
+
53
+ if tmp_dir.exists():
54
+ try:
55
+ shutil.rmtree(tmp_dir)
56
+ except OSError as e:
57
+ log.warning(f"LocalesCompiler | Failed to clean tmp_dir (maybe in use): {e}")
58
+
59
+ tmp_dir.mkdir(parents=True, exist_ok=True)
60
+
61
+ if not base_path.exists():
62
+ log.warning(f"LocalesCompiler | Source path not found: {base_path}")
63
+ return str(tmp_dir / "{locale}")
64
+
65
+ for lang_dir in base_path.iterdir():
66
+ if not lang_dir.is_dir():
67
+ continue
68
+
69
+ lang = lang_dir.name
70
+ compiled_content: list[str] = []
71
+
72
+ for ftl_file in sorted(lang_dir.glob("*.ftl")):
73
+ content = ftl_file.read_text(encoding="utf-8")
74
+ compiled_content.append(f"### Source: {ftl_file.name} ###\n{content}\n")
75
+
76
+ if compiled_content:
77
+ lang_tmp_dir = tmp_dir / lang
78
+ lang_tmp_dir.mkdir(exist_ok=True)
79
+ output_file = lang_tmp_dir / "messages.ftl"
80
+ output_file.write_text("\n".join(compiled_content), encoding="utf-8")
81
+ log.debug(f"LocalesCompiler | Compiled {lang} ({len(compiled_content)} files) → {output_file}")
82
+
83
+ return str(tmp_dir / "{locale}")
@@ -0,0 +1,24 @@
1
+ """
2
+ codex_bot.engine.middlewares — Ready-to-use middleware for Aiogram.
3
+
4
+ All middleware are "dumb" bridges between aiogram and the DI container.
5
+ No business logic — only infrastructure.
6
+
7
+ Connection (recommended order):
8
+ 1. UserValidationMiddleware — checks for user presence
9
+ 2. ThrottlingMiddleware — rate limiting (atomic SET NX)
10
+ 3. ContainerMiddleware — DI container injection
11
+ """
12
+
13
+ from codex_bot.engine.middlewares.container import ContainerMiddleware
14
+ from codex_bot.engine.middlewares.throttling import ThrottlingMiddleware
15
+ from codex_bot.engine.middlewares.user_validation import UserValidationMiddleware
16
+
17
+ __all__ = [
18
+ "UserValidationMiddleware",
19
+ "ThrottlingMiddleware",
20
+ "ContainerMiddleware",
21
+ ]
22
+
23
+ # FSMContextI18nManager is exported separately — requires aiogram-i18n[optional]
24
+ # from codex_bot.engine.middlewares.i18n import FSMContextI18nManager
@@ -0,0 +1,46 @@
1
+ """
2
+ ContainerMiddleware — DI container injection into handlers.
3
+
4
+ Adds `data["container"]` so that handlers don't import the container
5
+ directly — they receive it from the event context.
6
+ """
7
+
8
+ from collections.abc import Awaitable, Callable
9
+ from typing import Any
10
+
11
+ from aiogram import BaseMiddleware
12
+ from aiogram.types import TelegramObject
13
+
14
+
15
+ class ContainerMiddleware(BaseMiddleware):
16
+ """
17
+ Middleware for injecting a DI container into handlers.
18
+
19
+ Passes any container object via `data["container"]`.
20
+ Does not know about the specific container type — works with any object.
21
+
22
+ Args:
23
+ container: Project's DI container (any object).
24
+
25
+ Example:
26
+ ```python
27
+ container = BotContainer(settings=settings, redis=redis)
28
+ dp.update.middleware(ContainerMiddleware(container=container))
29
+
30
+ # In a handler:
31
+ async def my_handler(callback: CallbackQuery, container: BotContainer):
32
+ result = await container.booking_service.get_slots()
33
+ ```
34
+ """
35
+
36
+ def __init__(self, container: Any) -> None:
37
+ self.container = container
38
+
39
+ async def __call__(
40
+ self,
41
+ handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
42
+ event: TelegramObject,
43
+ data: dict[str, Any],
44
+ ) -> Any:
45
+ data["container"] = self.container
46
+ return await handler(event, data)