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.
- roomkit/AGENTS.md +362 -0
- roomkit/__init__.py +372 -0
- roomkit/_version.py +1 -0
- roomkit/ai_docs.py +93 -0
- roomkit/channels/__init__.py +194 -0
- roomkit/channels/ai.py +238 -0
- roomkit/channels/base.py +66 -0
- roomkit/channels/transport.py +115 -0
- roomkit/channels/websocket.py +85 -0
- roomkit/core/__init__.py +0 -0
- roomkit/core/_channel_ops.py +252 -0
- roomkit/core/_helpers.py +296 -0
- roomkit/core/_inbound.py +435 -0
- roomkit/core/_room_lifecycle.py +275 -0
- roomkit/core/circuit_breaker.py +84 -0
- roomkit/core/event_router.py +401 -0
- roomkit/core/framework.py +793 -0
- roomkit/core/hooks.py +232 -0
- roomkit/core/inbound_router.py +57 -0
- roomkit/core/locks.py +66 -0
- roomkit/core/rate_limiter.py +67 -0
- roomkit/core/retry.py +49 -0
- roomkit/core/router.py +24 -0
- roomkit/core/transcoder.py +85 -0
- roomkit/identity/__init__.py +0 -0
- roomkit/identity/base.py +27 -0
- roomkit/identity/mock.py +49 -0
- roomkit/llms.txt +52 -0
- roomkit/models/__init__.py +104 -0
- roomkit/models/channel.py +99 -0
- roomkit/models/context.py +35 -0
- roomkit/models/delivery.py +76 -0
- roomkit/models/enums.py +170 -0
- roomkit/models/event.py +203 -0
- roomkit/models/framework_event.py +19 -0
- roomkit/models/hook.py +68 -0
- roomkit/models/identity.py +81 -0
- roomkit/models/participant.py +34 -0
- roomkit/models/room.py +33 -0
- roomkit/models/task.py +36 -0
- roomkit/providers/__init__.py +0 -0
- roomkit/providers/ai/__init__.py +0 -0
- roomkit/providers/ai/base.py +140 -0
- roomkit/providers/ai/mock.py +33 -0
- roomkit/providers/anthropic/__init__.py +6 -0
- roomkit/providers/anthropic/ai.py +145 -0
- roomkit/providers/anthropic/config.py +14 -0
- roomkit/providers/elasticemail/__init__.py +6 -0
- roomkit/providers/elasticemail/config.py +16 -0
- roomkit/providers/elasticemail/email.py +97 -0
- roomkit/providers/email/__init__.py +0 -0
- roomkit/providers/email/base.py +46 -0
- roomkit/providers/email/mock.py +34 -0
- roomkit/providers/gemini/__init__.py +6 -0
- roomkit/providers/gemini/ai.py +153 -0
- roomkit/providers/gemini/config.py +14 -0
- roomkit/providers/http/__init__.py +15 -0
- roomkit/providers/http/base.py +33 -0
- roomkit/providers/http/config.py +14 -0
- roomkit/providers/http/mock.py +21 -0
- roomkit/providers/http/provider.py +105 -0
- roomkit/providers/http/webhook.py +33 -0
- roomkit/providers/messenger/__init__.py +15 -0
- roomkit/providers/messenger/base.py +33 -0
- roomkit/providers/messenger/config.py +17 -0
- roomkit/providers/messenger/facebook.py +95 -0
- roomkit/providers/messenger/mock.py +21 -0
- roomkit/providers/messenger/webhook.py +42 -0
- roomkit/providers/openai/__init__.py +6 -0
- roomkit/providers/openai/ai.py +155 -0
- roomkit/providers/openai/config.py +24 -0
- roomkit/providers/pydantic_ai/__init__.py +5 -0
- roomkit/providers/pydantic_ai/config.py +14 -0
- roomkit/providers/rcs/__init__.py +9 -0
- roomkit/providers/rcs/base.py +95 -0
- roomkit/providers/rcs/mock.py +78 -0
- roomkit/providers/sendgrid/__init__.py +5 -0
- roomkit/providers/sendgrid/config.py +13 -0
- roomkit/providers/sinch/__init__.py +6 -0
- roomkit/providers/sinch/config.py +22 -0
- roomkit/providers/sinch/sms.py +192 -0
- roomkit/providers/sms/__init__.py +15 -0
- roomkit/providers/sms/base.py +67 -0
- roomkit/providers/sms/meta.py +401 -0
- roomkit/providers/sms/mock.py +24 -0
- roomkit/providers/sms/phone.py +77 -0
- roomkit/providers/telnyx/__init__.py +21 -0
- roomkit/providers/telnyx/config.py +14 -0
- roomkit/providers/telnyx/rcs.py +352 -0
- roomkit/providers/telnyx/sms.py +231 -0
- roomkit/providers/twilio/__init__.py +18 -0
- roomkit/providers/twilio/config.py +19 -0
- roomkit/providers/twilio/rcs.py +183 -0
- roomkit/providers/twilio/sms.py +200 -0
- roomkit/providers/voicemeup/__init__.py +15 -0
- roomkit/providers/voicemeup/config.py +21 -0
- roomkit/providers/voicemeup/sms.py +374 -0
- roomkit/providers/whatsapp/__init__.py +0 -0
- roomkit/providers/whatsapp/base.py +44 -0
- roomkit/providers/whatsapp/mock.py +21 -0
- roomkit/py.typed +0 -0
- roomkit/realtime/__init__.py +17 -0
- roomkit/realtime/base.py +111 -0
- roomkit/realtime/memory.py +158 -0
- roomkit/sources/__init__.py +35 -0
- roomkit/sources/base.py +207 -0
- roomkit/sources/websocket.py +260 -0
- roomkit/store/__init__.py +0 -0
- roomkit/store/base.py +230 -0
- roomkit/store/memory.py +293 -0
- roomkit-0.1.0.dist-info/METADATA +567 -0
- roomkit-0.1.0.dist-info/RECORD +114 -0
- roomkit-0.1.0.dist-info/WHEEL +4 -0
- 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
|
roomkit/core/_helpers.py
ADDED
|
@@ -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
|
+
)
|