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,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Protocols for sender — Business interface for UI coordinate storage.
|
|
3
|
+
|
|
4
|
+
SenderStateStorageProtocol describes operations in terms of the domain area
|
|
5
|
+
("UI coordinates") rather than Redis terms (hash, key). The specific implementation
|
|
6
|
+
via Redis HASH remains on the project side.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@runtime_checkable
|
|
13
|
+
class SenderStateStorageProtocol(Protocol):
|
|
14
|
+
"""
|
|
15
|
+
Contract for UI coordinate storage (Menu and Content message IDs).
|
|
16
|
+
|
|
17
|
+
Implement this protocol in the project via Redis, PostgreSQL, or in-memory —
|
|
18
|
+
ViewSender and SenderManager do not know about the specific storage.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
```python
|
|
22
|
+
class RedisSenderStorage:
|
|
23
|
+
def __init__(self, redis: Redis): ...
|
|
24
|
+
|
|
25
|
+
async def get_sender_state(self, key: str) -> dict[str, int]:
|
|
26
|
+
data = await redis.hgetall(f"sender:{key}")
|
|
27
|
+
return {k: int(v) for k, v in data.items()}
|
|
28
|
+
|
|
29
|
+
async def save_sender_state(self, key: str, data: dict[str, int]) -> None:
|
|
30
|
+
await redis.hset(f"sender:{key}", mapping={k: str(v) for k, v in data.items()})
|
|
31
|
+
|
|
32
|
+
async def clear_sender_state(self, key: str) -> None:
|
|
33
|
+
await redis.delete(f"sender:{key}")
|
|
34
|
+
```
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
async def get_sender_state(self, key: str) -> dict[str, int]:
|
|
38
|
+
"""
|
|
39
|
+
Returns UI coordinates (message IDs) for a session.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
key: Session key (e.g., user_id string or channel session_id).
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Dictionary like {"menu_msg_id": 123, "content_msg_id": 124}.
|
|
46
|
+
Empty dictionary if no data exists.
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
async def save_sender_state(self, key: str, data: dict[str, int]) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Saves (partially updates) UI coordinates for a session.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
key: Session key.
|
|
56
|
+
data: Fields to update, e.g., {"menu_msg_id": 123}.
|
|
57
|
+
"""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
async def clear_sender_state(self, key: str) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Deletes all UI coordinates for a session.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
key: Session key.
|
|
66
|
+
"""
|
|
67
|
+
...
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SenderKeys — Key factory for UI coordinate storage.
|
|
3
|
+
|
|
4
|
+
Used by specific implementations of SenderStateStorageProtocol in projects.
|
|
5
|
+
Provided by the library as a ready-made standard for key naming.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SenderKeys:
|
|
10
|
+
"""
|
|
11
|
+
Key factory for SenderManager.
|
|
12
|
+
|
|
13
|
+
Standardizes key naming in the UI coordinate storage.
|
|
14
|
+
Use in your implementation of SenderStateStorageProtocol.
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
```python
|
|
18
|
+
key = SenderKeys.user(user_id=123456789)
|
|
19
|
+
# "sender:user:123456789"
|
|
20
|
+
|
|
21
|
+
key = SenderKeys.channel(session_id="booking_feed_1")
|
|
22
|
+
# "sender:channel:booking_feed_1"
|
|
23
|
+
```
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def user(user_id: int | str) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Key for storing UI coordinates of private correspondence.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
user_id: Telegram ID of the user.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Key string like `sender:user:<user_id>`.
|
|
36
|
+
"""
|
|
37
|
+
return f"sender:user:{user_id}"
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def channel(session_id: str) -> str:
|
|
41
|
+
"""
|
|
42
|
+
Key for storing UI coordinates of a channel or group.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
session_id: Unique identifier of the channel session.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Key string like `sender:channel:<session_id>`.
|
|
49
|
+
"""
|
|
50
|
+
return f"sender:channel:{session_id}"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SenderManager — Manager of UI coordinates (message IDs in Telegram).
|
|
3
|
+
|
|
4
|
+
Stores message_ids of two persistent bot messages (Menu and Content),
|
|
5
|
+
so that ViewSender can edit them instead of sending new ones.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from codex_bot.sender.protocols import SenderStateStorageProtocol
|
|
9
|
+
from codex_bot.sender.sender_keys import SenderKeys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SenderManager:
|
|
13
|
+
"""
|
|
14
|
+
Manager for UI coordinate storage.
|
|
15
|
+
|
|
16
|
+
Abstracts work with coordinates (menu_msg_id, content_msg_id)
|
|
17
|
+
on top of SenderStateStorageProtocol. ViewSender works only through it.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
storage: Implementation of SenderStateStorageProtocol (Redis, in-memory, etc.).
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
```python
|
|
24
|
+
manager = SenderManager(storage=redis_sender_storage)
|
|
25
|
+
coords = await manager.get_coords(session_key=123456)
|
|
26
|
+
# {"menu_msg_id": 10, "content_msg_id": 11}
|
|
27
|
+
|
|
28
|
+
await manager.update_coords(123456, {"menu_msg_id": 20})
|
|
29
|
+
await manager.clear_coords(123456)
|
|
30
|
+
```
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, storage: SenderStateStorageProtocol) -> None:
|
|
34
|
+
self.storage = storage
|
|
35
|
+
|
|
36
|
+
async def get_coords(self, session_key: int | str, is_channel: bool = False) -> dict[str, int]:
|
|
37
|
+
"""
|
|
38
|
+
Returns saved UI coordinates for a session.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
session_key: user_id (int) or session_id (str) for a channel.
|
|
42
|
+
is_channel: True — use channel key, False — use user key.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Dictionary {"menu_msg_id": int, "content_msg_id": int}.
|
|
46
|
+
Empty dictionary if no data exists.
|
|
47
|
+
"""
|
|
48
|
+
key = self._build_key(session_key, is_channel)
|
|
49
|
+
return await self.storage.get_sender_state(key)
|
|
50
|
+
|
|
51
|
+
async def update_coords(
|
|
52
|
+
self,
|
|
53
|
+
session_key: int | str,
|
|
54
|
+
coords: dict[str, int],
|
|
55
|
+
is_channel: bool = False,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""
|
|
58
|
+
Partially updates UI coordinates for a session.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
session_key: user_id or session_id.
|
|
62
|
+
coords: Fields to update (partial update).
|
|
63
|
+
is_channel: Key type.
|
|
64
|
+
"""
|
|
65
|
+
if not coords:
|
|
66
|
+
return
|
|
67
|
+
key = self._build_key(session_key, is_channel)
|
|
68
|
+
await self.storage.save_sender_state(key, coords)
|
|
69
|
+
|
|
70
|
+
async def clear_coords(self, session_key: int | str, is_channel: bool = False) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Deletes all UI coordinates for a session (state reset).
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
session_key: user_id or session_id.
|
|
76
|
+
is_channel: Key type.
|
|
77
|
+
"""
|
|
78
|
+
key = self._build_key(session_key, is_channel)
|
|
79
|
+
await self.storage.clear_sender_state(key)
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _build_key(session_key: int | str, is_channel: bool) -> str:
|
|
83
|
+
if is_channel:
|
|
84
|
+
return SenderKeys.channel(str(session_key))
|
|
85
|
+
return SenderKeys.user(session_key)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ViewSender — STATELESS service for sending and synchronizing Telegram bot UI.
|
|
3
|
+
|
|
4
|
+
Manages two persistent messages (Menu and Content):
|
|
5
|
+
edits them if they exist, creates new ones if not.
|
|
6
|
+
Stores their IDs via SenderManager to avoid cluttering the chat.
|
|
7
|
+
|
|
8
|
+
IMPORTANT: The class must be Stateless — it does not store user state in self.
|
|
9
|
+
ViewSender is a singleton. All context is passed through method arguments.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
from aiogram import Bot
|
|
16
|
+
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest
|
|
17
|
+
|
|
18
|
+
from ..base.view_dto import UnifiedViewDTO, ViewResultDTO
|
|
19
|
+
from .sender_manager import SenderManager
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ViewSender:
|
|
25
|
+
"""STATELESS service for sending and updating UI messages.
|
|
26
|
+
|
|
27
|
+
Works with two persistent bot messages in the chat:
|
|
28
|
+
|
|
29
|
+
- **Menu** — navigation block (section buttons).
|
|
30
|
+
- **Content** — information block (current feature data).
|
|
31
|
+
|
|
32
|
+
The ``send()`` algorithm:
|
|
33
|
+
|
|
34
|
+
1. Deletes the ``trigger_message`` (e.g., the ``/start`` command).
|
|
35
|
+
2. If ``clean_history=True`` — deletes old Menu and Content.
|
|
36
|
+
3. Edits existing messages (or creates new ones).
|
|
37
|
+
4. Saves current ``message_id`` via SenderManager.
|
|
38
|
+
|
|
39
|
+
.. note::
|
|
40
|
+
``alert_text`` from ``UnifiedViewDTO`` is intentionally not processed by ViewSender —
|
|
41
|
+
alert requires access to ``CallbackQuery.answer()``, which ViewSender does not have.
|
|
42
|
+
Call ``await call.answer(view.alert_text)`` in the handler before ``await sender.send(view)``.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
bot: Aiogram Bot instance.
|
|
46
|
+
manager: SenderManager for storing UI coordinates.
|
|
47
|
+
|
|
48
|
+
Example:
|
|
49
|
+
```python
|
|
50
|
+
sender = ViewSender(bot=bot, manager=sender_manager)
|
|
51
|
+
# In a handler:
|
|
52
|
+
if view.alert_text:
|
|
53
|
+
await call.answer(view.alert_text, show_alert=True)
|
|
54
|
+
await sender.send(view)
|
|
55
|
+
```
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, bot: Bot, manager: SenderManager) -> None:
|
|
59
|
+
self.bot = bot
|
|
60
|
+
self.manager = manager
|
|
61
|
+
|
|
62
|
+
async def send(self, view: UnifiedViewDTO) -> None:
|
|
63
|
+
"""Main UI synchronization method.
|
|
64
|
+
|
|
65
|
+
All context variables are local, without writing to self.
|
|
66
|
+
Safe for concurrent calls from different users.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
view: ``UnifiedViewDTO`` from the orchestrator. Must contain
|
|
70
|
+
``session_key`` and ``chat_id`` (filled by the Director).
|
|
71
|
+
"""
|
|
72
|
+
if not view.session_key or not view.chat_id:
|
|
73
|
+
log.error("ViewSender | missing session_key or chat_id in UnifiedViewDTO")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
# Local variables — each send() call is isolated
|
|
77
|
+
key = view.session_key
|
|
78
|
+
chat_id = view.chat_id
|
|
79
|
+
thread_id = view.message_thread_id
|
|
80
|
+
is_channel = self._detect_channel(view)
|
|
81
|
+
|
|
82
|
+
# 1. Delete trigger message (e.g., /start)
|
|
83
|
+
if view.trigger_message_id:
|
|
84
|
+
with contextlib.suppress(TelegramAPIError):
|
|
85
|
+
await self.bot.delete_message(
|
|
86
|
+
chat_id=chat_id,
|
|
87
|
+
message_id=view.trigger_message_id,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
coords = await self.manager.get_coords(key, is_channel)
|
|
91
|
+
|
|
92
|
+
# 2. Clean old UI
|
|
93
|
+
if view.clean_history:
|
|
94
|
+
await self._delete_coords(coords, chat_id)
|
|
95
|
+
coords = {}
|
|
96
|
+
await self.manager.clear_coords(key, is_channel)
|
|
97
|
+
|
|
98
|
+
# 3. Update Menu and Content (chat_id and thread_id explicitly via parameters)
|
|
99
|
+
old_menu_id = coords.get("menu_msg_id")
|
|
100
|
+
new_menu_id = await self._process_message(view.menu, old_menu_id, "MENU", chat_id, thread_id)
|
|
101
|
+
|
|
102
|
+
old_content_id = coords.get("content_msg_id")
|
|
103
|
+
new_content_id = await self._process_message(view.content, old_content_id, "CONTENT", chat_id, thread_id)
|
|
104
|
+
|
|
105
|
+
# 4. Save updated coordinates
|
|
106
|
+
updates: dict[str, int] = {}
|
|
107
|
+
if new_menu_id and new_menu_id != old_menu_id:
|
|
108
|
+
updates["menu_msg_id"] = new_menu_id
|
|
109
|
+
if new_content_id and new_content_id != old_content_id:
|
|
110
|
+
updates["content_msg_id"] = new_content_id
|
|
111
|
+
|
|
112
|
+
if updates:
|
|
113
|
+
await self.manager.update_coords(key, updates, is_channel)
|
|
114
|
+
|
|
115
|
+
async def _delete_coords(
|
|
116
|
+
self,
|
|
117
|
+
coords: dict[str, int],
|
|
118
|
+
chat_id: int | str,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Deletes Menu and Content messages from the chat.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
coords: Dictionary with ``menu_msg_id`` and ``content_msg_id``.
|
|
124
|
+
chat_id: Chat ID for deletion.
|
|
125
|
+
"""
|
|
126
|
+
for msg_id in (coords.get("menu_msg_id"), coords.get("content_msg_id")):
|
|
127
|
+
if msg_id:
|
|
128
|
+
with contextlib.suppress(TelegramAPIError):
|
|
129
|
+
await self.bot.delete_message(chat_id=chat_id, message_id=msg_id)
|
|
130
|
+
|
|
131
|
+
async def _process_message(
|
|
132
|
+
self,
|
|
133
|
+
view_dto: ViewResultDTO | None,
|
|
134
|
+
old_message_id: int | None,
|
|
135
|
+
log_prefix: str,
|
|
136
|
+
chat_id: int | str,
|
|
137
|
+
thread_id: int | None,
|
|
138
|
+
) -> int | None:
|
|
139
|
+
"""Edits an existing message or sends a new one.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
view_dto: Content to send. None — skip.
|
|
143
|
+
old_message_id: ID of the existing message to edit.
|
|
144
|
+
log_prefix: Log label (``"MENU"`` or ``"CONTENT"``).
|
|
145
|
+
chat_id: Target chat ID.
|
|
146
|
+
thread_id: Topic ID in a supergroup.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
ID of the current message (old or new). None on error.
|
|
150
|
+
"""
|
|
151
|
+
if not view_dto:
|
|
152
|
+
return old_message_id
|
|
153
|
+
|
|
154
|
+
if old_message_id:
|
|
155
|
+
try:
|
|
156
|
+
await self.bot.edit_message_text(
|
|
157
|
+
chat_id=chat_id,
|
|
158
|
+
message_id=old_message_id,
|
|
159
|
+
text=view_dto.text,
|
|
160
|
+
reply_markup=view_dto.kb,
|
|
161
|
+
parse_mode="HTML",
|
|
162
|
+
)
|
|
163
|
+
return old_message_id
|
|
164
|
+
except TelegramBadRequest as e:
|
|
165
|
+
if "message is not modified" in str(e).lower():
|
|
166
|
+
return old_message_id
|
|
167
|
+
# Message deleted or inaccessible — create new
|
|
168
|
+
except TelegramAPIError:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
sent = await self.bot.send_message(
|
|
173
|
+
chat_id=chat_id,
|
|
174
|
+
text=view_dto.text,
|
|
175
|
+
reply_markup=view_dto.kb,
|
|
176
|
+
message_thread_id=thread_id,
|
|
177
|
+
parse_mode="HTML",
|
|
178
|
+
)
|
|
179
|
+
log.debug(f"ViewSender [{log_prefix}] sent | chat={chat_id} msg={sent.message_id}")
|
|
180
|
+
return sent.message_id
|
|
181
|
+
except TelegramAPIError as e:
|
|
182
|
+
log.error(f"ViewSender [{log_prefix}] error | chat={chat_id} error='{e}'")
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _detect_channel(view: UnifiedViewDTO) -> bool:
|
|
187
|
+
"""Detects if the chat is a channel or a group.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
view: UnifiedViewDTO with chat data.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
``True`` if it's a channel, topic, or group with a negative chat_id.
|
|
194
|
+
"""
|
|
195
|
+
return (
|
|
196
|
+
view.mode in ("channel", "topic")
|
|
197
|
+
or (isinstance(view.chat_id, int) and view.chat_id < 0)
|
|
198
|
+
or str(view.chat_id).startswith("-")
|
|
199
|
+
or view.message_thread_id is not None
|
|
200
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from aiogram.filters.callback_data import CallbackData
|
|
2
|
+
|
|
3
|
+
# If this is a PAIRED feature (handling buttons from Redis notifications):
|
|
4
|
+
# 1. Import the base callback:
|
|
5
|
+
# from features.redis.{feature_key}.resources.callbacks import {class_name}Callback as BaseCallback
|
|
6
|
+
# 2. Inherit WITHOUT a new prefix (to catch the same events):
|
|
7
|
+
# class {class_name}Callback(BaseCallback):
|
|
8
|
+
# pass
|
|
9
|
+
|
|
10
|
+
# If this is a STANDALONE feature:
|
|
11
|
+
class {class_name}Callback(CallbackData, prefix="{feature_key}"):
|
|
12
|
+
"""Callback for the {class_name} feature."""
|
|
13
|
+
action: str
|
|
14
|
+
id: str | int
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from typing import Any, Protocol
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class {class_name}DataProvider(Protocol):
|
|
5
|
+
"""
|
|
6
|
+
Contract for accessing {class_name} feature data.
|
|
7
|
+
Implementation (Client or Repository) is injected via DI.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
async def get_data(self, user_id: int) -> Any:
|
|
11
|
+
"""Example data retrieval method."""
|
|
12
|
+
...
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from aiogram.fsm.state import State, StatesGroup
|
|
2
|
+
|
|
3
|
+
# 1. State Definition
|
|
4
|
+
class {class_name}States(StatesGroup):
|
|
5
|
+
main = State()
|
|
6
|
+
|
|
7
|
+
STATES = {class_name}States
|
|
8
|
+
|
|
9
|
+
# 2. Garbage Collector Settings
|
|
10
|
+
GARBAGE_COLLECT = True
|
|
11
|
+
|
|
12
|
+
# 3. Menu Settings
|
|
13
|
+
MENU_CONFIG = {{
|
|
14
|
+
"key": "{feature_key}",
|
|
15
|
+
"text": "{class_name}",
|
|
16
|
+
"description": "Description of the {class_name} feature",
|
|
17
|
+
"target_state": "{feature_key}",
|
|
18
|
+
"priority": 50,
|
|
19
|
+
"is_admin": False,
|
|
20
|
+
"is_superuser": False,
|
|
21
|
+
}}
|
|
22
|
+
|
|
23
|
+
# 4. Factory (DI)
|
|
24
|
+
def create_orchestrator(container):
|
|
25
|
+
from .logic.orchestrator import {class_name}Orchestrator
|
|
26
|
+
return {class_name}Orchestrator()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from .texts import {class_name}Texts
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class {class_name}Formatter:
|
|
6
|
+
"""Message formatting for the {class_name} feature."""
|
|
7
|
+
|
|
8
|
+
def format_main(self, payload: Any) -> str:
|
|
9
|
+
"""Formats the main screen text."""
|
|
10
|
+
user_name = payload.get("name", "User") if isinstance(payload, dict) else "User"
|
|
11
|
+
return (
|
|
12
|
+
f"{{{class_name}Texts.TITLE}}\n\n"
|
|
13
|
+
f"{{{class_name}Texts.DESCRIPTION}}\n"
|
|
14
|
+
f"User: {{user_name}}"
|
|
15
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from aiogram import Router, F
|
|
2
|
+
from aiogram.types import CallbackQuery
|
|
3
|
+
from aiogram.filters import StateFilter
|
|
4
|
+
|
|
5
|
+
from codex_bot.director import Director
|
|
6
|
+
from .feature_setting import {class_name}States
|
|
7
|
+
from .resources.callbacks import {class_name}Callback
|
|
8
|
+
|
|
9
|
+
router = Router(name="{feature_key}_router")
|
|
10
|
+
|
|
11
|
+
@router.callback_query(
|
|
12
|
+
{class_name}Callback.filter(F.action == "action"),
|
|
13
|
+
StateFilter({class_name}States.main)
|
|
14
|
+
)
|
|
15
|
+
async def handle_action(
|
|
16
|
+
call: CallbackQuery,
|
|
17
|
+
callback_data: {class_name}Callback,
|
|
18
|
+
state,
|
|
19
|
+
container,
|
|
20
|
+
):
|
|
21
|
+
await call.answer()
|
|
22
|
+
|
|
23
|
+
director = Director(
|
|
24
|
+
container=container,
|
|
25
|
+
state=state,
|
|
26
|
+
user_id=call.from_user.id,
|
|
27
|
+
chat_id=call.message.chat.id if call.message else None,
|
|
28
|
+
)
|
|
29
|
+
view_dto = await director.set_scene("{feature_key}", payload=callback_data)
|
|
30
|
+
|
|
31
|
+
if view_dto and hasattr(container, "view_sender"):
|
|
32
|
+
await container.view_sender.send(view_dto)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from loguru import logger as log
|
|
4
|
+
from codex_bot.redis import RedisRouter
|
|
5
|
+
|
|
6
|
+
# Router for events from Redis Stream
|
|
7
|
+
redis_router = RedisRouter()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@redis_router.message("your_event_type")
|
|
11
|
+
async def handle_{feature_key}_event(message_data: dict[str, Any], container: Any):
|
|
12
|
+
"""Handler for {class_name} events from Redis Stream.
|
|
13
|
+
|
|
14
|
+
Do NOT catch exceptions here — let them propagate to the dispatcher.
|
|
15
|
+
The dispatcher will trigger the retry scheduler if configured.
|
|
16
|
+
"""
|
|
17
|
+
log.info(f"{class_name} | Processing event type='{message_data.get('type')}'")
|
|
18
|
+
|
|
19
|
+
orchestrator = container.{container_key}
|
|
20
|
+
view_sender = container.view_sender
|
|
21
|
+
|
|
22
|
+
# Processing logic:
|
|
23
|
+
# view_dto = await orchestrator.handle_event(message_data)
|
|
24
|
+
# await view_sender.send(view_dto)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|
2
|
+
from .callbacks import {class_name}Callback
|
|
3
|
+
from .texts import {class_name}Texts
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_main_kb():
|
|
7
|
+
"""Main screen keyboard."""
|
|
8
|
+
builder = InlineKeyboardBuilder()
|
|
9
|
+
builder.button(
|
|
10
|
+
text={class_name}Texts.BUTTON_ACTION,
|
|
11
|
+
callback_data={class_name}Callback(action="action", id=0).pack(),
|
|
12
|
+
)
|
|
13
|
+
builder.adjust(1)
|
|
14
|
+
return builder.as_markup()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from typing import Any, TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
from codex_bot.base import BaseBotOrchestrator, UnifiedViewDTO, ViewResultDTO
|
|
4
|
+
from .ui.ui import {class_name}UI
|
|
5
|
+
from .feature_setting import {class_name}States
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from codex_bot.director import Director
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class {class_name}Orchestrator(BaseBotOrchestrator[Any]):
|
|
12
|
+
def __init__(self):
|
|
13
|
+
super().__init__(expected_state={class_name}States.main)
|
|
14
|
+
self.ui = {class_name}UI()
|
|
15
|
+
|
|
16
|
+
async def handle_entry(
|
|
17
|
+
self,
|
|
18
|
+
director: "Director",
|
|
19
|
+
payload: Any = None,
|
|
20
|
+
) -> UnifiedViewDTO:
|
|
21
|
+
"""Entry point into the feature. Called by Director on set_scene()."""
|
|
22
|
+
return await self.render(payload, director)
|
|
23
|
+
|
|
24
|
+
async def render_content(self, payload: Any, director: "Director") -> ViewResultDTO:
|
|
25
|
+
"""Content rendering logic. Override to implement feature UI."""
|
|
26
|
+
return self.ui.render_main(payload)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from loguru import logger as log
|
|
4
|
+
from codex_bot.base import UnifiedViewDTO
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class {class_name}Orchestrator:
|
|
8
|
+
"""Orchestrator for the background feature {class_name} (Redis)."""
|
|
9
|
+
|
|
10
|
+
def __init__(self):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
def handle_event(self, payload: dict[str, Any]) -> UnifiedViewDTO:
|
|
14
|
+
"""Incoming data processing."""
|
|
15
|
+
log.debug(f"{class_name}Orchestrator | Handling payload: {{payload}}")
|
|
16
|
+
# Implement logic here
|
|
17
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from codex_bot.base import ViewResultDTO
|
|
4
|
+
from .resources.formatters import {class_name}Formatter
|
|
5
|
+
from .resources.keyboards import build_main_kb
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class {class_name}UI:
|
|
9
|
+
"""
|
|
10
|
+
UI service for the {class_name} feature.
|
|
11
|
+
Uses formatters and keyboards from resources.
|
|
12
|
+
"""
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.formatter = {class_name}Formatter()
|
|
15
|
+
|
|
16
|
+
def render_main(self, payload: Any) -> ViewResultDTO:
|
|
17
|
+
text = self.formatter.format_main(payload)
|
|
18
|
+
kb = build_main_kb()
|
|
19
|
+
return ViewResultDTO(text=text, kb=kb)
|