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,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,7 @@
1
+ """
2
+ codex_bot.helper — Utilities for extracting context from Telegram events.
3
+ """
4
+
5
+ from codex_bot.helper.context_helper import ContextHelper
6
+
7
+ __all__ = ["ContextHelper"]
@@ -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
+ ]