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,215 @@
1
+ """
2
+ CLI commands for codex-bot feature scaffolding.
3
+
4
+ Used as the ``codex-bot`` entry point (see pyproject.toml).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ # Path to the library's built-in templates
14
+ _TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "feature"
15
+
16
+
17
+ def _load_template(name: str, templates_dir: Path) -> str:
18
+ """Loads a template from a ``.py.tpl`` file.
19
+
20
+ Args:
21
+ name: Template name without extension.
22
+ templates_dir: Directory containing templates.
23
+
24
+ Returns:
25
+ Content of the template file.
26
+
27
+ Raises:
28
+ FileNotFoundError: If the template is not found.
29
+ """
30
+ path = templates_dir / f"{name}.py.tpl"
31
+ if not path.exists():
32
+ raise FileNotFoundError(f"Template not found: {path}")
33
+ return path.read_text(encoding="utf-8")
34
+
35
+
36
+ def _get_template(base_name: str, suffix: str, templates_dir: Path) -> str:
37
+ """Loads a specific template, otherwise takes the base one.
38
+
39
+ Args:
40
+ base_name: Base template name.
41
+ suffix: Suffix for a specific template (e.g., ``"_redis"``).
42
+ templates_dir: Directory containing templates.
43
+
44
+ Returns:
45
+ Template content.
46
+ """
47
+ try:
48
+ return _load_template(f"{base_name}{suffix}", templates_dir)
49
+ except FileNotFoundError:
50
+ return _load_template(base_name, templates_dir)
51
+
52
+
53
+ def create_feature(
54
+ name: str,
55
+ feature_type: str,
56
+ base_dir: Path | None = None,
57
+ templates_dir: Path | None = None,
58
+ ) -> None:
59
+ """Creates the structure of a new feature based on templates.
60
+
61
+ Generates a ``features/{feature_type}/{name}/`` directory with files:
62
+ ``feature_setting.py``, ``handlers/handlers.py``, ``logic/orchestrator.py``,
63
+ ``ui/ui.py``, ``contracts/contract.py``, ``resources/``, and ``tests/``.
64
+
65
+ Args:
66
+ name: Feature name in ``snake_case``.
67
+ feature_type: Feature type — ``"telegram"`` or ``"redis"``.
68
+ base_dir: Project root directory. ``None`` → current directory.
69
+ templates_dir: Templates directory. ``None`` → built-in templates.
70
+
71
+ Raises:
72
+ SystemExit: If the feature already exists or a template is not found.
73
+
74
+ Example:
75
+ ```bash
76
+ codex-bot create-feature booking
77
+ codex-bot create-feature payment_notify --type redis
78
+ ```
79
+ """
80
+ base_dir = base_dir or Path.cwd()
81
+ templates_dir = templates_dir or _TEMPLATES_DIR
82
+
83
+ feature_base = base_dir / "features" / feature_type / name
84
+
85
+ if feature_base.exists():
86
+ print(f"❌ Error: Feature '{name}' already exists in features/{feature_type}/")
87
+ return
88
+
89
+ # Name conversions
90
+ class_name = "".join(word.capitalize() for word in name.split("_"))
91
+ feature_key = name.lower()
92
+ container_key = f"redis_{feature_key}" if feature_type == "redis" else feature_key
93
+
94
+ # Check for paired feature
95
+ other_type = "redis" if feature_type == "telegram" else "telegram"
96
+ has_pair = (base_dir / "features" / other_type / name).exists()
97
+
98
+ # Create directories
99
+ dirs = [
100
+ feature_base,
101
+ feature_base / "handlers",
102
+ feature_base / "logic",
103
+ feature_base / "ui",
104
+ feature_base / "resources",
105
+ feature_base / "contracts",
106
+ feature_base / "tests",
107
+ ]
108
+ for d in dirs:
109
+ d.mkdir(parents=True, exist_ok=True)
110
+ (d / "__init__.py").write_text("", encoding="utf-8")
111
+
112
+ suffix = "_redis" if feature_type == "redis" else ""
113
+
114
+ try:
115
+ templates: dict[str, str] = {
116
+ "feature.py": _get_template("feature", suffix, templates_dir),
117
+ "handlers.py": _get_template("handlers", suffix, templates_dir),
118
+ "orchestrator.py": _get_template("orchestrator", suffix, templates_dir),
119
+ "ui.py": _get_template("ui", "", templates_dir),
120
+ "contract.py": _get_template("contract", "", templates_dir),
121
+ "texts.py": _get_template("texts", "", templates_dir),
122
+ "keyboards.py": _get_template("keyboards", "", templates_dir),
123
+ "callbacks.py": _get_template("callbacks", "", templates_dir),
124
+ "formatters.py": _get_template("formatters", "", templates_dir),
125
+ "dto.py": _get_template("dto", "", templates_dir),
126
+ }
127
+ except FileNotFoundError as e:
128
+ print(f"❌ Error: Template file not found. {e}")
129
+ return
130
+
131
+ format_vars = {
132
+ "class_name": class_name,
133
+ "feature_key": feature_key,
134
+ "container_key": container_key,
135
+ "feature_type": feature_type,
136
+ }
137
+
138
+ files: dict[Path, str] = {
139
+ feature_base / "feature_setting.py": templates["feature.py"],
140
+ feature_base / "handlers" / "handlers.py": templates["handlers.py"],
141
+ feature_base / "logic" / "orchestrator.py": templates["orchestrator.py"],
142
+ feature_base / "ui" / "ui.py": templates["ui.py"],
143
+ feature_base / "contracts" / "contract.py": templates["contract.py"],
144
+ feature_base / "resources" / "texts.py": templates["texts.py"],
145
+ feature_base / "resources" / "keyboards.py": templates["keyboards.py"],
146
+ feature_base / "resources" / "callbacks.py": templates["callbacks.py"],
147
+ feature_base / "resources" / "formatters.py": templates["formatters.py"],
148
+ feature_base / "resources" / "dto.py": templates["dto.py"],
149
+ }
150
+
151
+ for file_path, template in files.items():
152
+ content = template.format(**format_vars)
153
+ file_path.write_text(content, encoding="utf-8")
154
+
155
+ # Export router from handlers/__init__.py
156
+ router_attr = "redis_router" if feature_type == "redis" else "router"
157
+ init_content = f"from .handlers import {router_attr}\n"
158
+ (feature_base / "handlers" / "__init__.py").write_text(init_content, encoding="utf-8")
159
+
160
+ print(f"\n✅ Feature '{name}' ({feature_type}) successfully created!")
161
+
162
+ if has_pair:
163
+ print(f"🔗 Paired feature found in '{other_type}'!")
164
+ if feature_type == "telegram":
165
+ print("💡 Tip: Configure inheritance in resources/callbacks.py to handle buttons from Redis notifications.")
166
+
167
+ setting_list = "INSTALLED_FEATURES" if feature_type == "telegram" else "INSTALLED_REDIS_FEATURES"
168
+ print(f"👉 Add 'features.{feature_type}.{name}' to {setting_list}")
169
+
170
+
171
+ def main() -> None:
172
+ """CLI entry point for ``codex-bot``.
173
+
174
+ Subcommands:
175
+ create-feature: Create a new feature.
176
+
177
+ Example:
178
+ ```bash
179
+ codex-bot create-feature booking
180
+ codex-bot create-feature payment_notify --type redis
181
+ ```
182
+ """
183
+ parser = argparse.ArgumentParser(
184
+ description="codex-bot: Aiogram Bot Management Framework",
185
+ formatter_class=argparse.RawDescriptionHelpFormatter,
186
+ )
187
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
188
+
189
+ create_parser = subparsers.add_parser("create-feature", help="Create a new feature")
190
+ create_parser.add_argument("name", help="Feature name in snake_case")
191
+ create_parser.add_argument(
192
+ "--type",
193
+ choices=["telegram", "redis"],
194
+ default="telegram",
195
+ help="Feature type (default: telegram)",
196
+ )
197
+
198
+ if len(sys.argv) == 1:
199
+ parser.print_help()
200
+ sys.exit(0)
201
+
202
+ args = parser.parse_args()
203
+
204
+ if args.command == "create-feature":
205
+ if not args.name:
206
+ create_parser.print_help()
207
+ else:
208
+ create_feature(args.name, args.type)
209
+ else:
210
+ parser.print_help()
211
+ sys.exit(1)
212
+
213
+
214
+ if __name__ == "__main__":
215
+ main()
@@ -0,0 +1,13 @@
1
+ """
2
+ codex_bot.director — Cross-feature transition coordinator.
3
+ """
4
+
5
+ from codex_bot.director.director import Director
6
+ from codex_bot.director.protocols import ContainerProtocol, OrchestratorProtocol, SceneConfig
7
+
8
+ __all__ = [
9
+ "Director",
10
+ "OrchestratorProtocol",
11
+ "ContainerProtocol",
12
+ "SceneConfig",
13
+ ]
@@ -0,0 +1,107 @@
1
+ """
2
+ Director — Coordinator of cross-feature transitions.
3
+
4
+ The Director knows how to transition from one feature to another: change the FSM state,
5
+ get the required orchestrator from the container, and call its handle_entry().
6
+ Orchestrators are stateless — the Director passes itself as context with each call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import Any
13
+
14
+ from aiogram.fsm.context import FSMContext
15
+
16
+ from ..base.view_dto import UnifiedViewDTO
17
+ from .protocols import ContainerProtocol, OrchestratorProtocol
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ class Director:
23
+ """Coordinator of transitions between features (scenes).
24
+
25
+ Instantiated in the handler for each incoming request.
26
+ Stores the request context (user_id, chat_id, state) and passes itself
27
+ to orchestrators as an argument — no mutable state in the orchestrator.
28
+
29
+ Args:
30
+ container: Project's DI container with a ``features`` attribute.
31
+ state: FSM context of the current user.
32
+ user_id: Telegram ID of the user.
33
+ chat_id: Target chat ID.
34
+ trigger_id: ID of the trigger message (e.g., /start) for subsequent deletion.
35
+
36
+ Example:
37
+ ```python
38
+ director = Director(
39
+ container=container,
40
+ state=state,
41
+ user_id=callback.from_user.id,
42
+ chat_id=callback.message.chat.id,
43
+ )
44
+ view = await director.set_scene(feature="booking", payload=None)
45
+ await sender.send(view)
46
+ ```
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ container: ContainerProtocol,
52
+ state: FSMContext | None = None,
53
+ user_id: int | None = None,
54
+ chat_id: int | None = None,
55
+ trigger_id: int | None = None,
56
+ ) -> None:
57
+ self.container = container
58
+ self.state = state
59
+ self.user_id = user_id
60
+ self.chat_id = chat_id
61
+ self.trigger_id = trigger_id
62
+
63
+ async def set_scene(self, feature: str, payload: Any = None) -> Any:
64
+ """Cross-feature transition: changes FSM state and calls the feature orchestrator.
65
+
66
+ Algorithm:
67
+ 1. Retrieves the orchestrator by key from ``container.features``.
68
+ 2. Sets the FSM state if the orchestrator has declared it.
69
+ 3. Passes itself to ``handle_entry(director=self, payload=payload)``.
70
+ 4. Enriches the result with ``chat_id`` and ``session_key`` as a fallback.
71
+
72
+ Args:
73
+ feature: Orchestrator key in ``container.features`` (e.g., ``"booking"``).
74
+ payload: Data to pass to ``handle_entry()``.
75
+
76
+ Returns:
77
+ UnifiedViewDTO or any orchestrator result. None if the feature is not found.
78
+ """
79
+ orchestrator = self.container.features.get(feature)
80
+
81
+ if orchestrator is None:
82
+ log.error(f"Director | unknown_feature='{feature}' user_id={self.user_id}")
83
+ return None
84
+
85
+ # 1. FSM state change
86
+ if self.state and hasattr(orchestrator, "expected_state") and orchestrator.expected_state:
87
+ await self.state.set_state(orchestrator.expected_state)
88
+
89
+ # 2. Call handle_entry (pass self as context) or render
90
+ if isinstance(orchestrator, OrchestratorProtocol):
91
+ view = await orchestrator.handle_entry(director=self, payload=payload)
92
+ elif hasattr(orchestrator, "render"):
93
+ view = await orchestrator.render(payload, self)
94
+ else:
95
+ log.warning(f"Director | orchestrator='{feature}' has no handle_entry or render")
96
+ return None
97
+
98
+ # 3. Fallback enrichment of UnifiedViewDTO with session data
99
+ if isinstance(view, UnifiedViewDTO):
100
+ view = view.model_copy(
101
+ update={
102
+ "chat_id": view.chat_id or self.chat_id,
103
+ "session_key": view.session_key or self.user_id,
104
+ }
105
+ )
106
+
107
+ return view
@@ -0,0 +1,80 @@
1
+ """
2
+ Protocols for Director — Dependency inversion without concrete classes.
3
+
4
+ OrchestratorProtocol describes the minimum contract of a stateless orchestrator,
5
+ ContainerProtocol — the minimum contract of the project's DI container.
6
+ The library does not know about specific implementations — only about interfaces.
7
+ """
8
+
9
+ from typing import Any, NamedTuple, Protocol, runtime_checkable
10
+
11
+ from aiogram.fsm.state import State
12
+
13
+
14
+ class SceneConfig(NamedTuple):
15
+ """Scene configuration: FSM state + entry-point service key.
16
+
17
+ Used in the project's SCENE_ROUTES to describe cross-feature transitions.
18
+
19
+ Attributes:
20
+ fsm_state: Aiogram State set when entering the scene.
21
+ entry_service: Orchestrator key in the container registry (e.g., ``"booking"``).
22
+
23
+ Example:
24
+ ```python
25
+ SCENE_ROUTES = {
26
+ "booking": SceneConfig(
27
+ fsm_state=BookingStates.main,
28
+ entry_service="booking",
29
+ ),
30
+ }
31
+ ```
32
+ """
33
+
34
+ fsm_state: State
35
+ entry_service: str
36
+
37
+
38
+ @runtime_checkable
39
+ class OrchestratorProtocol(Protocol):
40
+ """Minimum contract for a stateless feature orchestrator.
41
+
42
+ The Director works through this protocol without knowing about specific classes.
43
+ BaseBotOrchestrator implements it automatically.
44
+
45
+ The orchestrator must be stateless — it does not store user state.
46
+ Context is passed via the ``director`` argument on each call.
47
+ """
48
+
49
+ async def render(self, payload: Any, director: Any) -> Any:
50
+ """Renders content for the passed payload."""
51
+ ...
52
+
53
+ async def handle_entry(
54
+ self,
55
+ director: Any,
56
+ payload: Any = None,
57
+ ) -> Any:
58
+ """Entry point into the feature."""
59
+ ...
60
+
61
+
62
+ @runtime_checkable
63
+ class ContainerProtocol(Protocol):
64
+ """Minimum contract for the project's DI container.
65
+
66
+ The Director only requires the ``features`` attribute — a dictionary of orchestrators.
67
+ The specific BotContainer of the project must provide this attribute.
68
+
69
+ Attributes:
70
+ features: Dictionary of ``{feature_key: orchestrator}``.
71
+
72
+ Example:
73
+ ```python
74
+ class BotContainer:
75
+ def __init__(self):
76
+ self.features: dict[str, Any] = {}
77
+ ```
78
+ """
79
+
80
+ features: dict[str, OrchestratorProtocol]
@@ -0,0 +1,13 @@
1
+ """
2
+ codex_bot.engine — инфраструктурный движок (подкапотная магия).
3
+
4
+ Содержит модули, которые нужны для сборки бота, но не используются
5
+ разработчиком фичи в повседневной работе.
6
+
7
+ Подмодули:
8
+ engine.middlewares — системные middleware (Throttling, Security, Container, …)
9
+ engine.discovery — FeatureDiscoveryService (авто-обнаружение фич)
10
+ engine.factory — BotBuilder, compile_locales
11
+ engine.router_builder — collect_feature_routers, build_main_router
12
+ engine.http — BaseApiClient, ApiClientError
13
+ """
@@ -0,0 +1,7 @@
1
+ """
2
+ codex_bot.engine.discovery — Feature discovery and registration.
3
+ """
4
+
5
+ from codex_bot.engine.discovery.service import FeatureDiscoveryService
6
+
7
+ __all__ = ["FeatureDiscoveryService"]