roomkit 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 (114) hide show
  1. roomkit/AGENTS.md +362 -0
  2. roomkit/__init__.py +372 -0
  3. roomkit/_version.py +1 -0
  4. roomkit/ai_docs.py +93 -0
  5. roomkit/channels/__init__.py +194 -0
  6. roomkit/channels/ai.py +238 -0
  7. roomkit/channels/base.py +66 -0
  8. roomkit/channels/transport.py +115 -0
  9. roomkit/channels/websocket.py +85 -0
  10. roomkit/core/__init__.py +0 -0
  11. roomkit/core/_channel_ops.py +252 -0
  12. roomkit/core/_helpers.py +296 -0
  13. roomkit/core/_inbound.py +435 -0
  14. roomkit/core/_room_lifecycle.py +275 -0
  15. roomkit/core/circuit_breaker.py +84 -0
  16. roomkit/core/event_router.py +401 -0
  17. roomkit/core/framework.py +793 -0
  18. roomkit/core/hooks.py +232 -0
  19. roomkit/core/inbound_router.py +57 -0
  20. roomkit/core/locks.py +66 -0
  21. roomkit/core/rate_limiter.py +67 -0
  22. roomkit/core/retry.py +49 -0
  23. roomkit/core/router.py +24 -0
  24. roomkit/core/transcoder.py +85 -0
  25. roomkit/identity/__init__.py +0 -0
  26. roomkit/identity/base.py +27 -0
  27. roomkit/identity/mock.py +49 -0
  28. roomkit/llms.txt +52 -0
  29. roomkit/models/__init__.py +104 -0
  30. roomkit/models/channel.py +99 -0
  31. roomkit/models/context.py +35 -0
  32. roomkit/models/delivery.py +76 -0
  33. roomkit/models/enums.py +170 -0
  34. roomkit/models/event.py +203 -0
  35. roomkit/models/framework_event.py +19 -0
  36. roomkit/models/hook.py +68 -0
  37. roomkit/models/identity.py +81 -0
  38. roomkit/models/participant.py +34 -0
  39. roomkit/models/room.py +33 -0
  40. roomkit/models/task.py +36 -0
  41. roomkit/providers/__init__.py +0 -0
  42. roomkit/providers/ai/__init__.py +0 -0
  43. roomkit/providers/ai/base.py +140 -0
  44. roomkit/providers/ai/mock.py +33 -0
  45. roomkit/providers/anthropic/__init__.py +6 -0
  46. roomkit/providers/anthropic/ai.py +145 -0
  47. roomkit/providers/anthropic/config.py +14 -0
  48. roomkit/providers/elasticemail/__init__.py +6 -0
  49. roomkit/providers/elasticemail/config.py +16 -0
  50. roomkit/providers/elasticemail/email.py +97 -0
  51. roomkit/providers/email/__init__.py +0 -0
  52. roomkit/providers/email/base.py +46 -0
  53. roomkit/providers/email/mock.py +34 -0
  54. roomkit/providers/gemini/__init__.py +6 -0
  55. roomkit/providers/gemini/ai.py +153 -0
  56. roomkit/providers/gemini/config.py +14 -0
  57. roomkit/providers/http/__init__.py +15 -0
  58. roomkit/providers/http/base.py +33 -0
  59. roomkit/providers/http/config.py +14 -0
  60. roomkit/providers/http/mock.py +21 -0
  61. roomkit/providers/http/provider.py +105 -0
  62. roomkit/providers/http/webhook.py +33 -0
  63. roomkit/providers/messenger/__init__.py +15 -0
  64. roomkit/providers/messenger/base.py +33 -0
  65. roomkit/providers/messenger/config.py +17 -0
  66. roomkit/providers/messenger/facebook.py +95 -0
  67. roomkit/providers/messenger/mock.py +21 -0
  68. roomkit/providers/messenger/webhook.py +42 -0
  69. roomkit/providers/openai/__init__.py +6 -0
  70. roomkit/providers/openai/ai.py +155 -0
  71. roomkit/providers/openai/config.py +24 -0
  72. roomkit/providers/pydantic_ai/__init__.py +5 -0
  73. roomkit/providers/pydantic_ai/config.py +14 -0
  74. roomkit/providers/rcs/__init__.py +9 -0
  75. roomkit/providers/rcs/base.py +95 -0
  76. roomkit/providers/rcs/mock.py +78 -0
  77. roomkit/providers/sendgrid/__init__.py +5 -0
  78. roomkit/providers/sendgrid/config.py +13 -0
  79. roomkit/providers/sinch/__init__.py +6 -0
  80. roomkit/providers/sinch/config.py +22 -0
  81. roomkit/providers/sinch/sms.py +192 -0
  82. roomkit/providers/sms/__init__.py +15 -0
  83. roomkit/providers/sms/base.py +67 -0
  84. roomkit/providers/sms/meta.py +401 -0
  85. roomkit/providers/sms/mock.py +24 -0
  86. roomkit/providers/sms/phone.py +77 -0
  87. roomkit/providers/telnyx/__init__.py +21 -0
  88. roomkit/providers/telnyx/config.py +14 -0
  89. roomkit/providers/telnyx/rcs.py +352 -0
  90. roomkit/providers/telnyx/sms.py +231 -0
  91. roomkit/providers/twilio/__init__.py +18 -0
  92. roomkit/providers/twilio/config.py +19 -0
  93. roomkit/providers/twilio/rcs.py +183 -0
  94. roomkit/providers/twilio/sms.py +200 -0
  95. roomkit/providers/voicemeup/__init__.py +15 -0
  96. roomkit/providers/voicemeup/config.py +21 -0
  97. roomkit/providers/voicemeup/sms.py +374 -0
  98. roomkit/providers/whatsapp/__init__.py +0 -0
  99. roomkit/providers/whatsapp/base.py +44 -0
  100. roomkit/providers/whatsapp/mock.py +21 -0
  101. roomkit/py.typed +0 -0
  102. roomkit/realtime/__init__.py +17 -0
  103. roomkit/realtime/base.py +111 -0
  104. roomkit/realtime/memory.py +158 -0
  105. roomkit/sources/__init__.py +35 -0
  106. roomkit/sources/base.py +207 -0
  107. roomkit/sources/websocket.py +260 -0
  108. roomkit/store/__init__.py +0 -0
  109. roomkit/store/base.py +230 -0
  110. roomkit/store/memory.py +293 -0
  111. roomkit-0.1.0.dist-info/METADATA +567 -0
  112. roomkit-0.1.0.dist-info/RECORD +114 -0
  113. roomkit-0.1.0.dist-info/WHEEL +4 -0
  114. roomkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,252 @@
1
+ """ChannelOpsMixin — channel registration, binding, and state management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from roomkit.core._helpers import HelpersMixin
8
+ from roomkit.models.channel import ChannelBinding
9
+ from roomkit.models.enums import (
10
+ Access,
11
+ ChannelCategory,
12
+ ChannelType,
13
+ EventType,
14
+ HookTrigger,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from roomkit.channels.base import Channel
19
+ from roomkit.core.event_router import EventRouter
20
+ from roomkit.core.locks import RoomLockManager
21
+ from roomkit.store.base import ConversationStore
22
+
23
+
24
+ class ChannelOpsMixin(HelpersMixin):
25
+ """Channel registration, attachment, and binding operations."""
26
+
27
+ _store: ConversationStore
28
+ _channels: dict[str, Channel]
29
+ _lock_manager: RoomLockManager
30
+ _event_router: EventRouter | None
31
+
32
+ def register_channel(self, channel: Channel) -> None:
33
+ """Register a channel implementation by its ID."""
34
+ self._channels[channel.channel_id] = channel
35
+ self._event_router = None # Reset router cache
36
+
37
+ async def attach_channel(
38
+ self,
39
+ room_id: str,
40
+ channel_id: str,
41
+ channel_type: ChannelType | None = None,
42
+ category: ChannelCategory = ChannelCategory.TRANSPORT,
43
+ access: Access = Access.READ_WRITE,
44
+ visibility: str = "all",
45
+ **kwargs: Any,
46
+ ) -> ChannelBinding:
47
+ """Attach a registered channel to a room."""
48
+ from roomkit.core.framework import ChannelNotRegisteredError
49
+
50
+ async with self._lock_manager.locked(room_id):
51
+ await self.get_room(room_id) # type: ignore[attr-defined]
52
+ channel = self._channels.get(channel_id)
53
+ if channel is None:
54
+ raise ChannelNotRegisteredError(f"Channel {channel_id} not registered")
55
+ ct = channel_type or channel.channel_type
56
+ binding = ChannelBinding(
57
+ channel_id=channel_id,
58
+ room_id=room_id,
59
+ channel_type=ct,
60
+ category=category,
61
+ access=access,
62
+ visibility=visibility,
63
+ capabilities=channel.capabilities(),
64
+ **kwargs,
65
+ )
66
+ result = await self._store.add_binding(binding)
67
+ await self._emit_system_event(
68
+ room_id,
69
+ EventType.CHANNEL_ATTACHED,
70
+ code="channel_attached",
71
+ message=f"Channel {channel_id} attached ({access}, {visibility})",
72
+ data={
73
+ "channel_id": channel_id,
74
+ "access": str(access),
75
+ "visibility": visibility,
76
+ },
77
+ )
78
+ await self._fire_lifecycle_hook(
79
+ room_id,
80
+ HookTrigger.ON_CHANNEL_ATTACHED,
81
+ EventType.CHANNEL_ATTACHED,
82
+ code="channel_attached",
83
+ message=f"Channel {channel_id} attached",
84
+ data={
85
+ "channel_id": channel_id,
86
+ "access": str(access),
87
+ "visibility": visibility,
88
+ },
89
+ )
90
+ await self._emit_framework_event(
91
+ "room_channel_attached",
92
+ room_id=room_id,
93
+ channel_id=channel_id,
94
+ data={"access": str(access), "visibility": visibility},
95
+ )
96
+ return result
97
+
98
+ async def detach_channel(self, room_id: str, channel_id: str) -> bool:
99
+ """Detach a channel from a room."""
100
+ async with self._lock_manager.locked(room_id):
101
+ removed = await self._store.remove_binding(room_id, channel_id)
102
+ if removed:
103
+ await self._emit_system_event(
104
+ room_id,
105
+ EventType.CHANNEL_DETACHED,
106
+ code="channel_detached",
107
+ message=f"Channel {channel_id} detached",
108
+ data={"channel_id": channel_id},
109
+ )
110
+ await self._fire_lifecycle_hook(
111
+ room_id,
112
+ HookTrigger.ON_CHANNEL_DETACHED,
113
+ EventType.CHANNEL_DETACHED,
114
+ code="channel_detached",
115
+ message=f"Channel {channel_id} detached",
116
+ data={"channel_id": channel_id},
117
+ )
118
+ await self._emit_framework_event(
119
+ "room_channel_detached",
120
+ room_id=room_id,
121
+ channel_id=channel_id,
122
+ )
123
+ return removed
124
+
125
+ async def mute(self, room_id: str, channel_id: str) -> ChannelBinding:
126
+ """Mute a channel in a room."""
127
+ async with self._lock_manager.locked(room_id):
128
+ binding = await self._get_binding(room_id, channel_id)
129
+ updated = binding.model_copy(update={"muted": True})
130
+ result = await self._store.update_binding(updated)
131
+ await self._emit_system_event(
132
+ room_id,
133
+ EventType.CHANNEL_MUTED,
134
+ code="channel_muted",
135
+ message=f"Channel {channel_id} muted",
136
+ data={"channel_id": channel_id},
137
+ )
138
+ await self._fire_lifecycle_hook(
139
+ room_id,
140
+ HookTrigger.ON_CHANNEL_MUTED,
141
+ EventType.CHANNEL_MUTED,
142
+ code="channel_muted",
143
+ message=f"Channel {channel_id} muted",
144
+ data={"channel_id": channel_id},
145
+ )
146
+ return result
147
+
148
+ async def unmute(self, room_id: str, channel_id: str) -> ChannelBinding:
149
+ """Unmute a channel in a room."""
150
+ async with self._lock_manager.locked(room_id):
151
+ binding = await self._get_binding(room_id, channel_id)
152
+ updated = binding.model_copy(update={"muted": False})
153
+ result = await self._store.update_binding(updated)
154
+ await self._emit_system_event(
155
+ room_id,
156
+ EventType.CHANNEL_UNMUTED,
157
+ code="channel_unmuted",
158
+ message=f"Channel {channel_id} unmuted",
159
+ data={"channel_id": channel_id},
160
+ )
161
+ await self._fire_lifecycle_hook(
162
+ room_id,
163
+ HookTrigger.ON_CHANNEL_UNMUTED,
164
+ EventType.CHANNEL_UNMUTED,
165
+ code="channel_unmuted",
166
+ message=f"Channel {channel_id} unmuted",
167
+ data={"channel_id": channel_id},
168
+ )
169
+ return result
170
+
171
+ async def set_visibility(
172
+ self, room_id: str, channel_id: str, visibility: str
173
+ ) -> ChannelBinding:
174
+ """Set visibility for a channel in a room."""
175
+ async with self._lock_manager.locked(room_id):
176
+ binding = await self._get_binding(room_id, channel_id)
177
+ old_visibility = binding.visibility
178
+ updated = binding.model_copy(update={"visibility": visibility})
179
+ result = await self._store.update_binding(updated)
180
+ await self._emit_system_event(
181
+ room_id,
182
+ EventType.CHANNEL_UPDATED,
183
+ code="channel_visibility_changed",
184
+ message=f"Channel {channel_id} visibility: {old_visibility} -> {visibility}",
185
+ data={
186
+ "channel_id": channel_id,
187
+ "old_visibility": old_visibility,
188
+ "visibility": visibility,
189
+ },
190
+ )
191
+ return result
192
+
193
+ async def set_access(self, room_id: str, channel_id: str, access: Access) -> ChannelBinding:
194
+ """Set access level for a channel in a room."""
195
+ async with self._lock_manager.locked(room_id):
196
+ binding = await self._get_binding(room_id, channel_id)
197
+ old_access = binding.access
198
+ updated = binding.model_copy(update={"access": access})
199
+ result = await self._store.update_binding(updated)
200
+ await self._emit_system_event(
201
+ room_id,
202
+ EventType.CHANNEL_UPDATED,
203
+ code="channel_access_changed",
204
+ message=f"Channel {channel_id} access: {old_access} -> {access}",
205
+ data={
206
+ "channel_id": channel_id,
207
+ "old_access": str(old_access),
208
+ "access": str(access),
209
+ },
210
+ )
211
+ return result
212
+
213
+ async def update_binding_metadata(
214
+ self, room_id: str, channel_id: str, metadata: dict[str, Any]
215
+ ) -> ChannelBinding:
216
+ """Update metadata on a channel binding."""
217
+ async with self._lock_manager.locked(room_id):
218
+ binding = await self._get_binding(room_id, channel_id)
219
+ updated = binding.model_copy(update={"metadata": {**binding.metadata, **metadata}})
220
+ result = await self._store.update_binding(updated)
221
+ await self._emit_system_event(
222
+ room_id,
223
+ EventType.CHANNEL_UPDATED,
224
+ code="channel_metadata_updated",
225
+ message=f"Channel {channel_id} metadata updated",
226
+ data={"channel_id": channel_id, "keys": list(metadata.keys())},
227
+ )
228
+ return result
229
+
230
+ def get_channel(self, channel_id: str) -> Channel | None:
231
+ """Get a registered channel by ID."""
232
+ return self._channels.get(channel_id)
233
+
234
+ def list_channels(self) -> list[Channel]:
235
+ """List all registered channels."""
236
+ return list(self._channels.values())
237
+
238
+ async def get_binding(self, room_id: str, channel_id: str) -> ChannelBinding:
239
+ """Get a channel binding. Raises ChannelNotFoundError if missing."""
240
+ return await self._get_binding(room_id, channel_id)
241
+
242
+ async def list_bindings(self, room_id: str) -> list[ChannelBinding]:
243
+ """List all channel bindings for a room."""
244
+ return await self._store.list_bindings(room_id)
245
+
246
+ async def _get_binding(self, room_id: str, channel_id: str) -> ChannelBinding:
247
+ from roomkit.core.framework import ChannelNotFoundError
248
+
249
+ binding = await self._store.get_binding(room_id, channel_id)
250
+ if binding is None:
251
+ raise ChannelNotFoundError(f"Channel {channel_id} not in room {room_id}")
252
+ return binding
@@ -0,0 +1,296 @@
1
+ """HelpersMixin — internal helpers shared across framework mixins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Callable, Coroutine
7
+ from typing import TYPE_CHECKING, Any
8
+ from uuid import uuid4
9
+
10
+ from roomkit.models.context import RoomContext
11
+ from roomkit.models.enums import (
12
+ ChannelType,
13
+ EventStatus,
14
+ EventType,
15
+ HookTrigger,
16
+ IdentificationStatus,
17
+ )
18
+ from roomkit.models.event import EventSource, RoomEvent, SystemContent
19
+ from roomkit.models.framework_event import FrameworkEvent
20
+ from roomkit.models.identity import Identity, IdentityHookResult, IdentityResult
21
+ from roomkit.models.participant import Participant
22
+ from roomkit.models.task import Observation, Task
23
+
24
+ if TYPE_CHECKING:
25
+ from roomkit.core.hooks import HookEngine, IdentityHookRegistration
26
+ from roomkit.store.base import ConversationStore
27
+
28
+ logger = logging.getLogger("roomkit.framework")
29
+
30
+ FrameworkEventHandler = Callable[[FrameworkEvent], Coroutine[Any, Any, None]]
31
+ IdentityHookFn = Callable[
32
+ [RoomEvent, RoomContext, IdentityResult],
33
+ Coroutine[Any, Any, IdentityHookResult | None],
34
+ ]
35
+
36
+
37
+ class HelpersMixin:
38
+ """Internal helpers used by other framework mixins."""
39
+
40
+ _store: ConversationStore
41
+ _hook_engine: HookEngine
42
+ _event_handlers: list[tuple[str, FrameworkEventHandler]]
43
+ _identity_hooks: dict[HookTrigger, list[IdentityHookRegistration]]
44
+
45
+ # -- Internal helpers --
46
+
47
+ def _identity_hook_matches_event(
48
+ self, hook: IdentityHookRegistration, event: RoomEvent
49
+ ) -> bool:
50
+ """Check if an identity hook's filters match the given event."""
51
+ source = event.source
52
+
53
+ # All filters must pass (None means "match all")
54
+ type_ok = hook.channel_types is None or source.channel_type in hook.channel_types
55
+ id_ok = hook.channel_ids is None or source.channel_id in hook.channel_ids
56
+ dir_ok = hook.directions is None or source.direction in hook.directions
57
+
58
+ return type_ok and id_ok and dir_ok
59
+
60
+ async def _run_identity_hooks(
61
+ self,
62
+ room_id: str,
63
+ trigger: HookTrigger,
64
+ event: RoomEvent,
65
+ context: RoomContext,
66
+ id_result: IdentityResult,
67
+ ) -> IdentityHookResult | None:
68
+ """Run identity hooks for *trigger*, return the first non-None result."""
69
+ hooks = self._identity_hooks.get(trigger, [])
70
+ for hook_reg in hooks:
71
+ # Apply filters
72
+ if not self._identity_hook_matches_event(hook_reg, event):
73
+ continue
74
+ try:
75
+ result: IdentityHookResult | None = await hook_reg.fn(event, context, id_result)
76
+ if result is not None:
77
+ return result
78
+ except Exception:
79
+ logger.exception(
80
+ "Identity hook failed for trigger %s",
81
+ trigger,
82
+ extra={"room_id": room_id, "trigger": str(trigger)},
83
+ )
84
+ return None
85
+
86
+ async def _create_pending_participant(
87
+ self,
88
+ room_id: str,
89
+ event: RoomEvent,
90
+ id_result: IdentityResult,
91
+ ) -> Participant:
92
+ """Create a participant with pending identification status.
93
+
94
+ Idempotent: if a participant with the same ID already exists in the room,
95
+ the existing record is returned without creating a duplicate.
96
+ """
97
+ participant_id = event.source.participant_id or f"pending-{uuid4().hex[:8]}"
98
+ existing = await self._store.get_participant(room_id, participant_id)
99
+ if existing is not None:
100
+ return existing
101
+ candidate_ids = [c.id for c in id_result.candidates] if id_result.candidates else None
102
+ participant = Participant(
103
+ id=participant_id,
104
+ room_id=room_id,
105
+ channel_id=event.source.channel_id,
106
+ identification=IdentificationStatus.PENDING,
107
+ candidates=candidate_ids,
108
+ )
109
+ participant = await self._store.add_participant(participant)
110
+ await self._emit_system_event(
111
+ room_id,
112
+ EventType.PARTICIPANT_JOINED,
113
+ code="participant_joined_pending",
114
+ message=f"Participant {participant.id} joined with pending identification",
115
+ data={"participant_id": participant.id, "status": "pending"},
116
+ )
117
+ return participant
118
+
119
+ async def _ensure_identified_participant(
120
+ self,
121
+ room_id: str,
122
+ event: RoomEvent,
123
+ identity: Identity,
124
+ ) -> Participant:
125
+ """Ensure a participant record exists for an identified identity.
126
+
127
+ Idempotent: if a participant with the identity's ID already exists in the room,
128
+ the existing record is returned without creating a duplicate.
129
+ """
130
+ existing = await self._store.get_participant(room_id, identity.id)
131
+ if existing is not None:
132
+ # Update identification status if it was pending
133
+ if existing.identification != IdentificationStatus.IDENTIFIED:
134
+ existing = existing.model_copy(
135
+ update={
136
+ "identification": IdentificationStatus.IDENTIFIED,
137
+ "identity_id": identity.id,
138
+ "display_name": identity.display_name or existing.display_name,
139
+ }
140
+ )
141
+ existing = await self._store.update_participant(existing)
142
+ return existing
143
+
144
+ participant = Participant(
145
+ id=identity.id,
146
+ room_id=room_id,
147
+ channel_id=event.source.channel_id,
148
+ display_name=identity.display_name,
149
+ identification=IdentificationStatus.IDENTIFIED,
150
+ identity_id=identity.id,
151
+ )
152
+ participant = await self._store.add_participant(participant)
153
+ await self._emit_system_event(
154
+ room_id,
155
+ EventType.PARTICIPANT_JOINED,
156
+ code="participant_joined_identified",
157
+ message=f"Participant {participant.id} joined as identified",
158
+ data={"participant_id": participant.id, "status": "identified"},
159
+ )
160
+ return participant
161
+
162
+ async def _fire_lifecycle_hook(
163
+ self,
164
+ room_id: str,
165
+ trigger: HookTrigger,
166
+ event_type: EventType,
167
+ code: str,
168
+ message: str,
169
+ data: dict[str, Any] | None = None,
170
+ ) -> None:
171
+ """Fire an async lifecycle hook with a synthetic system event."""
172
+ event = RoomEvent(
173
+ room_id=room_id,
174
+ type=event_type,
175
+ source=EventSource(channel_id="system", channel_type=ChannelType.SYSTEM),
176
+ content=SystemContent(body=message, code=code, data=data or {}),
177
+ status=EventStatus.DELIVERED,
178
+ visibility="internal",
179
+ )
180
+ try:
181
+ context = await self._build_context(room_id)
182
+ except Exception:
183
+ # Room may not exist yet (e.g. ON_ROOM_CREATED before bindings exist)
184
+ room = await self._store.get_room(room_id)
185
+ if room is None:
186
+ return
187
+ context = RoomContext(room=room, bindings=[])
188
+ await self._hook_engine.run_async_hooks(room_id, trigger, event, context)
189
+
190
+ async def _persist_side_effects(
191
+ self,
192
+ room_id: str,
193
+ tasks: list[Task],
194
+ observations: list[Observation],
195
+ event: RoomEvent,
196
+ context: RoomContext,
197
+ ) -> None:
198
+ """Persist tasks and observations, fire ON_TASK_CREATED hooks for new tasks."""
199
+ persisted_tasks: list[Task] = []
200
+ for task in tasks:
201
+ try:
202
+ await self._store.add_task(task)
203
+ persisted_tasks.append(task)
204
+ except Exception:
205
+ logger.exception(
206
+ "Failed to persist task %s",
207
+ task.id,
208
+ extra={"room_id": room_id, "task_id": task.id},
209
+ )
210
+ for observation in observations:
211
+ try:
212
+ await self._store.add_observation(observation)
213
+ except Exception:
214
+ logger.exception(
215
+ "Failed to persist observation %s",
216
+ observation.id,
217
+ extra={"room_id": room_id, "observation_id": observation.id},
218
+ )
219
+ # Fire ON_TASK_CREATED hooks only for successfully persisted tasks
220
+ for task in persisted_tasks:
221
+ task_event = RoomEvent(
222
+ room_id=room_id,
223
+ type=EventType.TASK_CREATED,
224
+ source=event.source,
225
+ content=event.content,
226
+ status=EventStatus.DELIVERED,
227
+ visibility="internal",
228
+ metadata={"task_id": task.id, "task_title": task.title},
229
+ )
230
+ await self._hook_engine.run_async_hooks(
231
+ room_id, HookTrigger.ON_TASK_CREATED, task_event, context
232
+ )
233
+
234
+ async def _emit_system_event(
235
+ self,
236
+ room_id: str,
237
+ event_type: EventType,
238
+ code: str,
239
+ message: str,
240
+ data: dict[str, Any] | None = None,
241
+ ) -> None:
242
+ """Emit a system event to the room timeline (internal/audit)."""
243
+ count = await self._store.get_event_count(room_id)
244
+ event = RoomEvent(
245
+ room_id=room_id,
246
+ type=event_type,
247
+ source=EventSource(channel_id="system", channel_type=ChannelType.SYSTEM),
248
+ content=SystemContent(body=message, code=code, data=data or {}),
249
+ status=EventStatus.DELIVERED,
250
+ visibility="internal",
251
+ index=count,
252
+ )
253
+ await self._store.add_event(event)
254
+
255
+ async def _build_context(self, room_id: str) -> RoomContext:
256
+ """Build a RoomContext for the given room."""
257
+ room = await self._store.get_room(room_id)
258
+ if room is None:
259
+ from roomkit.core.framework import RoomNotFoundError
260
+
261
+ raise RoomNotFoundError(f"Room {room_id} not found")
262
+ bindings = await self._store.list_bindings(room_id)
263
+ participants = await self._store.list_participants(room_id)
264
+ recent = await self._store.list_events(room_id, offset=0, limit=50)
265
+ return RoomContext(
266
+ room=room,
267
+ bindings=bindings,
268
+ participants=participants,
269
+ recent_events=recent,
270
+ )
271
+
272
+ async def _emit_framework_event(
273
+ self,
274
+ event_type: str,
275
+ room_id: str | None = None,
276
+ channel_id: str | None = None,
277
+ event_id: str | None = None,
278
+ data: dict[str, Any] | None = None,
279
+ ) -> None:
280
+ """Emit a framework event to handlers registered for *event_type*."""
281
+ fw_event = FrameworkEvent(
282
+ type=event_type,
283
+ room_id=room_id,
284
+ channel_id=channel_id,
285
+ event_id=event_id,
286
+ data=data or {},
287
+ )
288
+ for filter_type, handler in self._event_handlers:
289
+ if filter_type == fw_event.type:
290
+ try:
291
+ await handler(fw_event)
292
+ except Exception:
293
+ logger.exception(
294
+ "Framework event handler failed",
295
+ extra={"event_type": fw_event.type, "room_id": fw_event.room_id},
296
+ )