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,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,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,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,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)
|