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,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BaseStateManager — Isolated feature data storage within FSM.
|
|
3
|
+
|
|
4
|
+
Each feature works with its own namespaced key in the FSM storage,
|
|
5
|
+
eliminating conflicts between features of the same bot.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
|
|
10
|
+
from aiogram.fsm.context import FSMContext
|
|
11
|
+
|
|
12
|
+
from .state_helper import StateHelper
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseStateManager:
|
|
18
|
+
"""
|
|
19
|
+
Base FSM state manager for a feature (draft).
|
|
20
|
+
|
|
21
|
+
Isolates specific feature data under the `draft:<feature_key>` key
|
|
22
|
+
within the user's general FSM storage. This prevents collisions
|
|
23
|
+
between different features operating in the same FSM session.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
state: FSM context of the current user.
|
|
27
|
+
feature_key: Unique feature key (e.g., `"booking"`, `"profile"`).
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
```python
|
|
31
|
+
manager = BaseStateManager(state, feature_key="booking")
|
|
32
|
+
await manager.update(date="2024-01-15", time="14:00")
|
|
33
|
+
payload = await manager.get_payload()
|
|
34
|
+
# {"date": "2024-01-15", "time": "14:00"}
|
|
35
|
+
await manager.clear()
|
|
36
|
+
```
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, state: FSMContext, feature_key: str) -> None:
|
|
40
|
+
self.state = state
|
|
41
|
+
self.storage_key = f"draft:{feature_key}"
|
|
42
|
+
|
|
43
|
+
async def get_payload(self) -> dict[str, Any]:
|
|
44
|
+
"""
|
|
45
|
+
Returns all data of the feature draft.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Dictionary with current data. Empty dictionary if no data exists.
|
|
49
|
+
"""
|
|
50
|
+
result = await StateHelper.get_value(self.state, self.storage_key, {})
|
|
51
|
+
return dict(result) if isinstance(result, dict) else {}
|
|
52
|
+
|
|
53
|
+
async def update(self, **kwargs: Any) -> dict[str, Any]:
|
|
54
|
+
"""
|
|
55
|
+
Updates the draft with the passed fields (partial update).
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
**kwargs: Fields to update.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Updated dictionary with draft data.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
```python
|
|
65
|
+
await manager.update(name="Alice", age=30)
|
|
66
|
+
```
|
|
67
|
+
"""
|
|
68
|
+
current = await self.get_payload()
|
|
69
|
+
current.update(kwargs)
|
|
70
|
+
await StateHelper.update_value(self.state, self.storage_key, current)
|
|
71
|
+
return current
|
|
72
|
+
|
|
73
|
+
async def clear(self) -> None:
|
|
74
|
+
"""Completely removes the draft key from FSM storage.
|
|
75
|
+
|
|
76
|
+
Unlike resetting to ``{key: {}}``, it completely removes the key,
|
|
77
|
+
leaving no empty "zombie dictionaries" in Redis.
|
|
78
|
+
Does not affect data of other features in the same FSM storage.
|
|
79
|
+
"""
|
|
80
|
+
await StateHelper.clear_key(self.state, self.storage_key)
|
|
81
|
+
|
|
82
|
+
async def set_value(self, key: str, value: Any) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Sets one specific value.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
key: Field key.
|
|
88
|
+
value: Value to save.
|
|
89
|
+
"""
|
|
90
|
+
await self.update(**{key: value})
|
|
91
|
+
|
|
92
|
+
async def get_value(self, key: str, default: Any = None) -> Any:
|
|
93
|
+
"""
|
|
94
|
+
Returns a specific value from the draft.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
key: Field key.
|
|
98
|
+
default: Default value if the key is not found.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Field value or default.
|
|
102
|
+
"""
|
|
103
|
+
payload = await self.get_payload()
|
|
104
|
+
return payload.get(key, default)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ContextHelper — Extraction of BaseBotContext from Telegram events.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from aiogram.types import CallbackQuery, Message
|
|
6
|
+
|
|
7
|
+
from codex_bot.base.context_dto import BaseBotContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ContextHelper:
|
|
11
|
+
"""
|
|
12
|
+
Helper for extracting BaseBotContext from Message or CallbackQuery.
|
|
13
|
+
|
|
14
|
+
Normalizes user_id: if from_user is missing (e.g., a post in a channel),
|
|
15
|
+
it uses chat_id as a fallback for session uniqueness.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
```python
|
|
19
|
+
ctx = ContextHelper.extract_base_context(callback)
|
|
20
|
+
director = Director(container=container, state=state,
|
|
21
|
+
user_id=ctx.user_id, chat_id=ctx.chat_id)
|
|
22
|
+
```
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def extract_base_context(event: Message | CallbackQuery) -> BaseBotContext:
|
|
27
|
+
"""
|
|
28
|
+
Extracts base IDs from an event.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
event: Message or CallbackQuery from the user.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Immutable BaseBotContext with user_id, chat_id, message_id, thread_id.
|
|
35
|
+
"""
|
|
36
|
+
user_id = event.from_user.id if event.from_user else 0
|
|
37
|
+
chat_id = user_id
|
|
38
|
+
message_id: int | None = None
|
|
39
|
+
thread_id: int | None = None
|
|
40
|
+
|
|
41
|
+
if isinstance(event, CallbackQuery):
|
|
42
|
+
if isinstance(event.message, Message):
|
|
43
|
+
chat_id = event.message.chat.id
|
|
44
|
+
message_id = event.message.message_id
|
|
45
|
+
thread_id = event.message.message_thread_id
|
|
46
|
+
if user_id == 0:
|
|
47
|
+
user_id = chat_id
|
|
48
|
+
|
|
49
|
+
elif isinstance(event, Message):
|
|
50
|
+
chat_id = event.chat.id
|
|
51
|
+
message_id = event.message_id
|
|
52
|
+
thread_id = event.message_thread_id
|
|
53
|
+
if user_id == 0:
|
|
54
|
+
user_id = chat_id
|
|
55
|
+
|
|
56
|
+
return BaseBotContext(
|
|
57
|
+
user_id=user_id,
|
|
58
|
+
chat_id=chat_id,
|
|
59
|
+
message_id=message_id,
|
|
60
|
+
message_thread_id=thread_id,
|
|
61
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_bot.redis — Redis Stream router, dispatcher, and processor.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from codex_bot.redis.dispatcher import BotRedisDispatcher, RetrySchedulerProtocol
|
|
6
|
+
from codex_bot.redis.router import RedisRouter
|
|
7
|
+
from codex_bot.redis.stream_processor import RedisStreamProcessor, StreamStorageProtocol
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"RedisRouter",
|
|
11
|
+
"BotRedisDispatcher",
|
|
12
|
+
"RetrySchedulerProtocol",
|
|
13
|
+
"RedisStreamProcessor",
|
|
14
|
+
"StreamStorageProtocol",
|
|
15
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BotRedisDispatcher — Dispatcher for messages from Redis Stream.
|
|
3
|
+
|
|
4
|
+
Works on the principle of aiogram.Dispatcher, but for Redis Stream:
|
|
5
|
+
registers handlers via decorators or include_router(),
|
|
6
|
+
dispatches incoming messages by type.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .router import FilterFunc, HandlerFunc, RedisRouter
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RetrySchedulerProtocol:
|
|
19
|
+
"""
|
|
20
|
+
Protocol for a retry scheduler (ARQ or similar).
|
|
21
|
+
|
|
22
|
+
Implement and pass to BotRedisDispatcher for automatic
|
|
23
|
+
rescheduling of failed messages to a retry queue.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
async def schedule_retry(self, stream_name: str, payload: dict[str, Any], delay: int = 60) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Schedules message reprocessing.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
stream_name: Redis Stream name.
|
|
32
|
+
payload: Message data for reprocessing.
|
|
33
|
+
delay: Delay in seconds before retry.
|
|
34
|
+
"""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BotRedisDispatcher:
|
|
39
|
+
"""
|
|
40
|
+
Redis Stream message dispatcher for a Telegram bot.
|
|
41
|
+
|
|
42
|
+
Allows registering handlers via decorators (@dispatcher.on_message)
|
|
43
|
+
or via routers (include_router). When processing a message:
|
|
44
|
+
|
|
45
|
+
1. Determines the type by the ``"type"`` field in the payload.
|
|
46
|
+
2. Finds suitable handlers (considering filters).
|
|
47
|
+
3. On handler error — if a ``retry_scheduler`` exists, passes the
|
|
48
|
+
task to it and returns control (Stream ACK will occur in the processor).
|
|
49
|
+
If there is no scheduler or it fails — re-raises the exception (no ACK).
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
retry_scheduler: Optional retry scheduler (ARQ, etc.).
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
```python
|
|
56
|
+
dispatcher = BotRedisDispatcher()
|
|
57
|
+
|
|
58
|
+
@dispatcher.on_message("booking.confirmed")
|
|
59
|
+
async def on_booking(payload: dict, container) -> None:
|
|
60
|
+
await container.notification_service.send(payload["user_id"])
|
|
61
|
+
|
|
62
|
+
# Connecting a router from a feature:
|
|
63
|
+
dispatcher.include_router(notifications_redis_router)
|
|
64
|
+
|
|
65
|
+
# Setting dependencies before start:
|
|
66
|
+
dispatcher.setup(container=container)
|
|
67
|
+
await dispatcher.process_message({"type": "booking.confirmed", ...})
|
|
68
|
+
```
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, retry_scheduler: RetrySchedulerProtocol | None = None) -> None:
|
|
72
|
+
self._container: Any = None
|
|
73
|
+
self._retry_scheduler = retry_scheduler
|
|
74
|
+
self._handlers: dict[str, list[tuple[HandlerFunc, FilterFunc | None]]] = {}
|
|
75
|
+
log.info("BotRedisDispatcher | initialized")
|
|
76
|
+
|
|
77
|
+
def setup(self, container: Any) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Sets the DI container before processing begins.
|
|
80
|
+
|
|
81
|
+
If a handler needs a Bot to send messages — it retrieves it
|
|
82
|
+
directly from ``container.bot``.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
container: Project's DI container.
|
|
86
|
+
"""
|
|
87
|
+
self._container = container
|
|
88
|
+
|
|
89
|
+
def include_router(self, router: RedisRouter) -> None:
|
|
90
|
+
"""
|
|
91
|
+
Connects a RedisRouter with its handlers.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
router: Router from a feature.
|
|
95
|
+
"""
|
|
96
|
+
for message_type, handlers in router.handlers.items():
|
|
97
|
+
if message_type not in self._handlers:
|
|
98
|
+
self._handlers[message_type] = []
|
|
99
|
+
self._handlers[message_type].extend(handlers)
|
|
100
|
+
log.info(f"BotRedisDispatcher | included router types={list(router.handlers.keys())}")
|
|
101
|
+
|
|
102
|
+
def on_message(
|
|
103
|
+
self,
|
|
104
|
+
message_type: str,
|
|
105
|
+
filter_func: "FilterFunc | None" = None,
|
|
106
|
+
) -> Callable[[HandlerFunc], HandlerFunc]:
|
|
107
|
+
"""
|
|
108
|
+
Decorator for registering a handler directly in the dispatcher.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
message_type: Redis Stream message type.
|
|
112
|
+
filter_func: Optional payload -> bool filter.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Decorator.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def decorator(handler: HandlerFunc) -> HandlerFunc:
|
|
119
|
+
if message_type not in self._handlers:
|
|
120
|
+
self._handlers[message_type] = []
|
|
121
|
+
self._handlers[message_type].append((handler, filter_func))
|
|
122
|
+
return handler
|
|
123
|
+
|
|
124
|
+
return decorator
|
|
125
|
+
|
|
126
|
+
async def process_message(self, message_data: dict[str, Any]) -> None:
|
|
127
|
+
"""
|
|
128
|
+
Dispatches an incoming Redis Stream message.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
message_data: Message payload. Required field: ``"type"``.
|
|
132
|
+
"""
|
|
133
|
+
if not self._container:
|
|
134
|
+
log.error("BotRedisDispatcher | container not set — call setup() first")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
msg_type = message_data.get("type")
|
|
138
|
+
if not msg_type:
|
|
139
|
+
log.warning(f"BotRedisDispatcher | message without 'type' field: {message_data}")
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
handlers = self._handlers.get(msg_type, [])
|
|
143
|
+
if not handlers:
|
|
144
|
+
log.debug(f"BotRedisDispatcher | no handlers for type='{msg_type}'")
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
for handler, filter_func in handlers:
|
|
148
|
+
try:
|
|
149
|
+
if filter_func is None or filter_func(message_data):
|
|
150
|
+
log.debug(f"BotRedisDispatcher | calling {handler.__name__} for type='{msg_type}'")
|
|
151
|
+
await handler(message_data, self._container)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
log.error(f"BotRedisDispatcher | handler {handler.__name__} failed: {e}")
|
|
154
|
+
|
|
155
|
+
if self._retry_scheduler:
|
|
156
|
+
try:
|
|
157
|
+
await self._retry_scheduler.schedule_retry(
|
|
158
|
+
stream_name="bot_events",
|
|
159
|
+
payload=message_data,
|
|
160
|
+
delay=60,
|
|
161
|
+
)
|
|
162
|
+
log.info(f"BotRedisDispatcher | retry scheduled for type='{msg_type}'")
|
|
163
|
+
# Responsibility passed to scheduler — ACK will occur in processor
|
|
164
|
+
return
|
|
165
|
+
except Exception as retry_err:
|
|
166
|
+
log.error(f"BotRedisDispatcher | retry scheduling failed: {retry_err}")
|
|
167
|
+
|
|
168
|
+
# No scheduler or it failed — re-raise, no ACK (PEL)
|
|
169
|
+
raise
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RedisRouter — Router for grouping Redis Stream handlers.
|
|
3
|
+
|
|
4
|
+
Analogous to aiogram.Router, but for messages from Redis Stream.
|
|
5
|
+
Allows organizing handlers modularly by feature.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
# Types for readability
|
|
12
|
+
HandlerFunc = Callable[[dict[str, Any], Any], Any]
|
|
13
|
+
FilterFunc = Callable[[dict[str, Any]], bool]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RedisRouter:
|
|
17
|
+
"""
|
|
18
|
+
Router for grouping Redis Stream handlers by message type.
|
|
19
|
+
|
|
20
|
+
Used in feature_setting.py of Redis features. After creation,
|
|
21
|
+
it is connected to BotRedisDispatcher via include_router().
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
```python
|
|
25
|
+
redis_router = RedisRouter()
|
|
26
|
+
|
|
27
|
+
@redis_router.message("notification.created")
|
|
28
|
+
async def handle_notification(payload: dict, container) -> None:
|
|
29
|
+
user_id = payload["user_id"]
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
# With a filter:
|
|
33
|
+
@redis_router.message("notification.created", filter_func=lambda p: p.get("urgent"))
|
|
34
|
+
async def handle_urgent(payload: dict, container) -> None:
|
|
35
|
+
...
|
|
36
|
+
```
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
# {message_type: [(handler, filter_func), ...]}
|
|
41
|
+
self._handlers: dict[str, list[tuple[HandlerFunc, FilterFunc | None]]] = {}
|
|
42
|
+
|
|
43
|
+
def message(
|
|
44
|
+
self,
|
|
45
|
+
message_type: str,
|
|
46
|
+
filter_func: FilterFunc | None = None,
|
|
47
|
+
) -> Callable[[HandlerFunc], HandlerFunc]:
|
|
48
|
+
"""
|
|
49
|
+
Decorator for registering a handler for a Redis Stream message type.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
message_type: String message type (e.g., "booking.confirmed").
|
|
53
|
+
filter_func: Optional filter — callable(payload) -> bool.
|
|
54
|
+
The handler is called only if the filter returns True.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Decorator returning the original handler unchanged.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def decorator(handler: HandlerFunc) -> HandlerFunc:
|
|
61
|
+
if message_type not in self._handlers:
|
|
62
|
+
self._handlers[message_type] = []
|
|
63
|
+
self._handlers[message_type].append((handler, filter_func))
|
|
64
|
+
return handler
|
|
65
|
+
|
|
66
|
+
return decorator
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def handlers(self) -> dict[str, list[tuple[HandlerFunc, FilterFunc | None]]]:
|
|
70
|
+
"""Registered handlers (read-only view)."""
|
|
71
|
+
return self._handlers
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RedisStreamProcessor — Redis Stream polling loop (Consumer Group).
|
|
3
|
+
|
|
4
|
+
Reads messages from Redis Stream via a Consumer Group, passes them
|
|
5
|
+
to a callback function (usually BotRedisDispatcher.process_message),
|
|
6
|
+
and acknowledges successful processing via ACK.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from typing import Any, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class StreamStorageProtocol(Protocol):
|
|
20
|
+
"""
|
|
21
|
+
Redis Stream adapter protocol for RedisStreamProcessor.
|
|
22
|
+
|
|
23
|
+
Implement in the project on top of redis-py or any other client.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
```python
|
|
27
|
+
class RedisStreamAdapter:
|
|
28
|
+
def __init__(self, redis: Redis): self.redis = redis
|
|
29
|
+
|
|
30
|
+
async def create_group(self, stream: str, group: str) -> None:
|
|
31
|
+
with contextlib.suppress(ResponseError):
|
|
32
|
+
await self.redis.xgroup_create(stream, group, id="0", mkstream=True)
|
|
33
|
+
|
|
34
|
+
async def read_events(self, stream_name, group_name, consumer_name, count):
|
|
35
|
+
result = await self.redis.xreadgroup(
|
|
36
|
+
group_name, consumer_name, {stream_name: ">"}, count=count, block=0
|
|
37
|
+
)
|
|
38
|
+
if not result:
|
|
39
|
+
return []
|
|
40
|
+
return [(msg_id, data) for _, messages in result for msg_id, data in messages]
|
|
41
|
+
|
|
42
|
+
async def ack_event(self, stream_name, group_name, message_id) -> None:
|
|
43
|
+
await self.redis.xack(stream_name, group_name, message_id)
|
|
44
|
+
```
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
async def create_group(self, stream_name: str, group_name: str) -> None:
|
|
48
|
+
"""Creates a Consumer Group (idempotently)."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
async def read_events(
|
|
52
|
+
self,
|
|
53
|
+
stream_name: str,
|
|
54
|
+
group_name: str,
|
|
55
|
+
consumer_name: str,
|
|
56
|
+
count: int,
|
|
57
|
+
) -> list[tuple[str, dict[str, Any]]]:
|
|
58
|
+
"""
|
|
59
|
+
Reads a batch of unread messages from the group.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of pairs (message_id, data_dict).
|
|
63
|
+
"""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
async def ack_event(self, stream_name: str, group_name: str, message_id: str) -> None:
|
|
67
|
+
"""Acknowledges message processing (XACK)."""
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
MessageCallback = Callable[[dict[str, Any]], Awaitable[None]]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class RedisStreamProcessor:
|
|
75
|
+
"""
|
|
76
|
+
Redis Stream processor: reads messages and passes them to a callback.
|
|
77
|
+
|
|
78
|
+
Starts a background asyncio task (_consume_loop) that continuously
|
|
79
|
+
reads messages from Redis Stream via a Consumer Group.
|
|
80
|
+
On errors — recreates the group (e.g., after a Redis restart).
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
storage: Stream adapter (StreamStorageProtocol).
|
|
84
|
+
stream_name: Redis Stream name (e.g., "bot_events").
|
|
85
|
+
consumer_group_name: Consumer Group name.
|
|
86
|
+
consumer_name: Unique consumer name (e.g., "bot-worker-1").
|
|
87
|
+
batch_count: Number of messages per reading cycle.
|
|
88
|
+
poll_interval: Wait interval (sec) if there are no messages.
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
```python
|
|
92
|
+
processor = RedisStreamProcessor(
|
|
93
|
+
storage=redis_stream_adapter,
|
|
94
|
+
stream_name="bot_events",
|
|
95
|
+
consumer_group_name="bot_group",
|
|
96
|
+
consumer_name="bot-1",
|
|
97
|
+
)
|
|
98
|
+
processor.set_message_callback(dispatcher.process_message)
|
|
99
|
+
await processor.start_listening()
|
|
100
|
+
```
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
storage: StreamStorageProtocol,
|
|
106
|
+
stream_name: str,
|
|
107
|
+
consumer_group_name: str,
|
|
108
|
+
consumer_name: str,
|
|
109
|
+
batch_count: int = 10,
|
|
110
|
+
poll_interval: float = 1.0,
|
|
111
|
+
) -> None:
|
|
112
|
+
self.storage = storage
|
|
113
|
+
self.stream_name = stream_name
|
|
114
|
+
self.group_name = consumer_group_name
|
|
115
|
+
self.consumer_name = consumer_name
|
|
116
|
+
self.batch_count = batch_count
|
|
117
|
+
self.poll_interval = poll_interval
|
|
118
|
+
|
|
119
|
+
self.is_running = False
|
|
120
|
+
self._callback: MessageCallback | None = None
|
|
121
|
+
self._task: asyncio.Task[None] | None = None
|
|
122
|
+
|
|
123
|
+
def set_message_callback(self, callback: MessageCallback) -> None:
|
|
124
|
+
"""
|
|
125
|
+
Sets the callback for processing each message.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
callback: Async callable(payload: dict) -> None.
|
|
129
|
+
"""
|
|
130
|
+
self._callback = callback
|
|
131
|
+
|
|
132
|
+
async def start_listening(self) -> None:
|
|
133
|
+
"""
|
|
134
|
+
Starts the background Stream reading loop.
|
|
135
|
+
|
|
136
|
+
Creates a Consumer Group (up to 5 attempts), then starts _consume_loop
|
|
137
|
+
as an asyncio Task.
|
|
138
|
+
"""
|
|
139
|
+
if self.is_running:
|
|
140
|
+
log.warning("RedisStreamProcessor | already running")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
for attempt in range(1, 6):
|
|
144
|
+
try:
|
|
145
|
+
await self.storage.create_group(self.stream_name, self.group_name)
|
|
146
|
+
break
|
|
147
|
+
except Exception as e:
|
|
148
|
+
log.warning(f"RedisStreamProcessor | create_group attempt {attempt}/5: {e}")
|
|
149
|
+
if attempt < 5:
|
|
150
|
+
await asyncio.sleep(3)
|
|
151
|
+
else:
|
|
152
|
+
log.error("RedisStreamProcessor | failed to create group, giving up")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
self.is_running = True
|
|
156
|
+
self._task = asyncio.create_task(self._consume_loop())
|
|
157
|
+
log.info(
|
|
158
|
+
f"RedisStreamProcessor | listening stream='{self.stream_name}' "
|
|
159
|
+
f"group='{self.group_name}' consumer='{self.consumer_name}'"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
async def stop_listening(self) -> None:
|
|
163
|
+
"""Stops the reading loop and correctly cancels the asyncio Task."""
|
|
164
|
+
self.is_running = False
|
|
165
|
+
if self._task and not self._task.done():
|
|
166
|
+
self._task.cancel()
|
|
167
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
168
|
+
await self._task
|
|
169
|
+
log.info("RedisStreamProcessor | stopped")
|
|
170
|
+
|
|
171
|
+
async def _consume_loop(self) -> None:
|
|
172
|
+
try:
|
|
173
|
+
while self.is_running:
|
|
174
|
+
try:
|
|
175
|
+
messages = await self.storage.read_events(
|
|
176
|
+
stream_name=self.stream_name,
|
|
177
|
+
group_name=self.group_name,
|
|
178
|
+
consumer_name=self.consumer_name,
|
|
179
|
+
count=self.batch_count,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if not messages:
|
|
183
|
+
await asyncio.sleep(self.poll_interval)
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
for message_id, data in messages:
|
|
187
|
+
await self._process_single(message_id, data)
|
|
188
|
+
|
|
189
|
+
except asyncio.CancelledError:
|
|
190
|
+
raise # propagate immediately, do not catch in general except
|
|
191
|
+
except Exception as e:
|
|
192
|
+
log.error(f"RedisStreamProcessor | consume loop error: {e}")
|
|
193
|
+
if "NOGROUP" in str(e):
|
|
194
|
+
log.warning("RedisStreamProcessor | consumer group missing, recreating...")
|
|
195
|
+
try:
|
|
196
|
+
await self.storage.create_group(self.stream_name, self.group_name)
|
|
197
|
+
except Exception as create_err:
|
|
198
|
+
log.error(f"RedisStreamProcessor | recreate failed: {create_err}")
|
|
199
|
+
await asyncio.sleep(5)
|
|
200
|
+
except asyncio.CancelledError:
|
|
201
|
+
log.info("RedisStreamProcessor | consume loop cancelled")
|
|
202
|
+
raise # asyncio requires propagating CancelledError
|
|
203
|
+
|
|
204
|
+
async def _process_single(self, message_id: str, data: dict[str, Any]) -> None:
|
|
205
|
+
try:
|
|
206
|
+
if self._callback:
|
|
207
|
+
await self._callback(data)
|
|
208
|
+
await self.storage.ack_event(self.stream_name, self.group_name, message_id)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
log.error(f"RedisStreamProcessor | failed to process message {message_id}: {e}")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_bot.sender — ViewSender, SenderManager, and UI coordinate storage protocols.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from codex_bot.sender.protocols import SenderStateStorageProtocol
|
|
6
|
+
from codex_bot.sender.sender_keys import SenderKeys
|
|
7
|
+
from codex_bot.sender.sender_manager import SenderManager
|
|
8
|
+
from codex_bot.sender.view_sender import ViewSender
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"SenderStateStorageProtocol",
|
|
12
|
+
"SenderKeys",
|
|
13
|
+
"SenderManager",
|
|
14
|
+
"ViewSender",
|
|
15
|
+
]
|