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
roomkit/core/_inbound.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""InboundMixin — inbound message processing pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections import deque
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from roomkit.core._helpers import HelpersMixin
|
|
12
|
+
from roomkit.models.context import RoomContext
|
|
13
|
+
from roomkit.models.delivery import InboundMessage, InboundResult
|
|
14
|
+
from roomkit.models.enums import (
|
|
15
|
+
ChannelType,
|
|
16
|
+
EventStatus,
|
|
17
|
+
HookTrigger,
|
|
18
|
+
IdentificationStatus,
|
|
19
|
+
)
|
|
20
|
+
from roomkit.models.event import RoomEvent
|
|
21
|
+
from roomkit.models.hook import InjectedEvent
|
|
22
|
+
from roomkit.models.identity import IdentityResult
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from roomkit.channels.base import Channel
|
|
26
|
+
from roomkit.core.inbound_router import InboundRoomRouter
|
|
27
|
+
from roomkit.core.locks import RoomLockManager
|
|
28
|
+
from roomkit.identity.base import IdentityResolver
|
|
29
|
+
from roomkit.store.base import ConversationStore
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("roomkit.framework")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InboundMixin(HelpersMixin):
|
|
35
|
+
"""Inbound message processing pipeline."""
|
|
36
|
+
|
|
37
|
+
_store: ConversationStore
|
|
38
|
+
_channels: dict[str, Channel]
|
|
39
|
+
_lock_manager: RoomLockManager
|
|
40
|
+
_identity_resolver: IdentityResolver | None
|
|
41
|
+
_identity_channel_types: set[ChannelType] | None
|
|
42
|
+
_identity_timeout: float
|
|
43
|
+
_process_timeout: float
|
|
44
|
+
_inbound_router: InboundRoomRouter
|
|
45
|
+
_max_chain_depth: int
|
|
46
|
+
|
|
47
|
+
async def process_inbound(self, message: InboundMessage) -> InboundResult:
|
|
48
|
+
"""Process an inbound message through the full pipeline."""
|
|
49
|
+
from roomkit.core.framework import ChannelNotRegisteredError
|
|
50
|
+
|
|
51
|
+
channel = self._channels.get(message.channel_id)
|
|
52
|
+
if channel is None:
|
|
53
|
+
raise ChannelNotRegisteredError(f"Channel {message.channel_id} not registered")
|
|
54
|
+
|
|
55
|
+
# Route to room (or auto-create)
|
|
56
|
+
room_id = await self._inbound_router.route(
|
|
57
|
+
channel_id=message.channel_id,
|
|
58
|
+
channel_type=channel.channel_type,
|
|
59
|
+
participant_id=message.sender_id,
|
|
60
|
+
)
|
|
61
|
+
if room_id is None:
|
|
62
|
+
# Auto-create room and attach channel
|
|
63
|
+
room = await self.create_room() # type: ignore[attr-defined]
|
|
64
|
+
room_id = room.id
|
|
65
|
+
await self.attach_channel(room_id, message.channel_id) # type: ignore[attr-defined]
|
|
66
|
+
|
|
67
|
+
context = await self._build_context(room_id)
|
|
68
|
+
|
|
69
|
+
# Let channel process inbound
|
|
70
|
+
event = await channel.handle_inbound(message, context)
|
|
71
|
+
|
|
72
|
+
# Identity resolution pipeline (RFC §7)
|
|
73
|
+
# Skip if channel type not in identity_channel_types filter (when set)
|
|
74
|
+
resolver = self._identity_resolver
|
|
75
|
+
should_resolve = resolver is not None and (
|
|
76
|
+
self._identity_channel_types is None
|
|
77
|
+
or channel.channel_type in self._identity_channel_types
|
|
78
|
+
)
|
|
79
|
+
if should_resolve and resolver is not None:
|
|
80
|
+
try:
|
|
81
|
+
id_result = await asyncio.wait_for(
|
|
82
|
+
resolver.resolve(message, context),
|
|
83
|
+
timeout=self._identity_timeout,
|
|
84
|
+
)
|
|
85
|
+
except TimeoutError:
|
|
86
|
+
logger.warning(
|
|
87
|
+
"Identity resolution timed out after %.1fs",
|
|
88
|
+
self._identity_timeout,
|
|
89
|
+
extra={"room_id": room_id, "channel_id": message.channel_id},
|
|
90
|
+
)
|
|
91
|
+
await self._emit_framework_event(
|
|
92
|
+
"identity_timeout",
|
|
93
|
+
room_id=room_id,
|
|
94
|
+
channel_id=message.channel_id,
|
|
95
|
+
data={"timeout": self._identity_timeout},
|
|
96
|
+
)
|
|
97
|
+
id_result = IdentityResult(status=IdentificationStatus.UNKNOWN)
|
|
98
|
+
|
|
99
|
+
# Backfill address and channel_type from the message if not set by resolver
|
|
100
|
+
# This ensures identity hooks always have access to sender info
|
|
101
|
+
updates = {}
|
|
102
|
+
if id_result.address is None:
|
|
103
|
+
updates["address"] = message.sender_id
|
|
104
|
+
if id_result.channel_type is None:
|
|
105
|
+
updates["channel_type"] = str(channel.channel_type)
|
|
106
|
+
if updates:
|
|
107
|
+
id_result = id_result.model_copy(update=updates)
|
|
108
|
+
|
|
109
|
+
if id_result.status == IdentificationStatus.IDENTIFIED and id_result.identity:
|
|
110
|
+
# Known identity — stamp participant_id and ensure participant record
|
|
111
|
+
event = event.model_copy(
|
|
112
|
+
update={
|
|
113
|
+
"source": event.source.model_copy(
|
|
114
|
+
update={"participant_id": id_result.identity.id}
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
await self._ensure_identified_participant(room_id, event, id_result.identity)
|
|
119
|
+
|
|
120
|
+
elif id_result.status in (
|
|
121
|
+
IdentificationStatus.AMBIGUOUS,
|
|
122
|
+
IdentificationStatus.PENDING,
|
|
123
|
+
):
|
|
124
|
+
# Multiple candidates or pending — run identity-specific hooks
|
|
125
|
+
hook_result = await self._run_identity_hooks(
|
|
126
|
+
room_id, HookTrigger.ON_IDENTITY_AMBIGUOUS, event, context, id_result
|
|
127
|
+
)
|
|
128
|
+
# Also fire regular async hooks for observation/logging
|
|
129
|
+
await self._hook_engine.run_async_hooks(
|
|
130
|
+
room_id, HookTrigger.ON_IDENTITY_AMBIGUOUS, event, context
|
|
131
|
+
)
|
|
132
|
+
if (
|
|
133
|
+
hook_result
|
|
134
|
+
and hook_result.status == IdentificationStatus.IDENTIFIED
|
|
135
|
+
and hook_result.identity
|
|
136
|
+
):
|
|
137
|
+
event = event.model_copy(
|
|
138
|
+
update={
|
|
139
|
+
"source": event.source.model_copy(
|
|
140
|
+
update={"participant_id": hook_result.identity.id}
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
await self._ensure_identified_participant(room_id, event, hook_result.identity)
|
|
145
|
+
elif hook_result and hook_result.status == IdentificationStatus.CHALLENGE_SENT:
|
|
146
|
+
if hook_result.inject:
|
|
147
|
+
await self._deliver_injected_events([hook_result.inject], room_id, context)
|
|
148
|
+
return InboundResult(blocked=True, reason="identity_challenge_sent")
|
|
149
|
+
elif hook_result and hook_result.status == IdentificationStatus.REJECTED:
|
|
150
|
+
return InboundResult(
|
|
151
|
+
blocked=True,
|
|
152
|
+
reason=hook_result.reason or "identity_rejected",
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
# No hook resolved it — create participant with pending status
|
|
156
|
+
await self._create_pending_participant(room_id, event, id_result)
|
|
157
|
+
|
|
158
|
+
elif id_result.status in (
|
|
159
|
+
IdentificationStatus.UNKNOWN,
|
|
160
|
+
IdentificationStatus.REJECTED,
|
|
161
|
+
):
|
|
162
|
+
# No match or rejected — run identity-specific hooks
|
|
163
|
+
hook_result = await self._run_identity_hooks(
|
|
164
|
+
room_id, HookTrigger.ON_IDENTITY_UNKNOWN, event, context, id_result
|
|
165
|
+
)
|
|
166
|
+
# Also fire regular async hooks for observation/logging
|
|
167
|
+
await self._hook_engine.run_async_hooks(
|
|
168
|
+
room_id, HookTrigger.ON_IDENTITY_UNKNOWN, event, context
|
|
169
|
+
)
|
|
170
|
+
if hook_result and hook_result.status == IdentificationStatus.REJECTED:
|
|
171
|
+
return InboundResult(
|
|
172
|
+
blocked=True,
|
|
173
|
+
reason=hook_result.reason or "unknown_sender",
|
|
174
|
+
)
|
|
175
|
+
elif (
|
|
176
|
+
hook_result
|
|
177
|
+
and hook_result.status == IdentificationStatus.IDENTIFIED
|
|
178
|
+
and hook_result.identity
|
|
179
|
+
):
|
|
180
|
+
event = event.model_copy(
|
|
181
|
+
update={
|
|
182
|
+
"source": event.source.model_copy(
|
|
183
|
+
update={"participant_id": hook_result.identity.id}
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
await self._ensure_identified_participant(room_id, event, hook_result.identity)
|
|
188
|
+
|
|
189
|
+
# Process under room lock
|
|
190
|
+
async with self._lock_manager.locked(room_id):
|
|
191
|
+
try:
|
|
192
|
+
return await asyncio.wait_for(
|
|
193
|
+
self._process_locked(event, room_id, context),
|
|
194
|
+
timeout=self._process_timeout,
|
|
195
|
+
)
|
|
196
|
+
except TimeoutError:
|
|
197
|
+
logger.error(
|
|
198
|
+
"Process locked timed out after %.1fs",
|
|
199
|
+
self._process_timeout,
|
|
200
|
+
extra={"room_id": room_id, "event_id": event.id},
|
|
201
|
+
)
|
|
202
|
+
await self._emit_framework_event(
|
|
203
|
+
"process_timeout",
|
|
204
|
+
room_id=room_id,
|
|
205
|
+
event_id=event.id,
|
|
206
|
+
data={"timeout": self._process_timeout},
|
|
207
|
+
)
|
|
208
|
+
return InboundResult(blocked=True, reason="process_timeout")
|
|
209
|
+
|
|
210
|
+
async def _process_locked(
|
|
211
|
+
self, event: RoomEvent, room_id: str, context: RoomContext
|
|
212
|
+
) -> InboundResult:
|
|
213
|
+
"""Process an event under the room lock."""
|
|
214
|
+
# Rebuild context under lock to prevent stale reads
|
|
215
|
+
context = await self._build_context(room_id)
|
|
216
|
+
|
|
217
|
+
# Idempotency check (inside lock to prevent TOCTOU race)
|
|
218
|
+
if event.idempotency_key and await self._store.check_idempotency(
|
|
219
|
+
room_id, event.idempotency_key
|
|
220
|
+
):
|
|
221
|
+
logger.info(
|
|
222
|
+
"Duplicate event %s",
|
|
223
|
+
event.idempotency_key,
|
|
224
|
+
extra={"room_id": room_id, "idempotency_key": event.idempotency_key},
|
|
225
|
+
)
|
|
226
|
+
return InboundResult(blocked=True, reason="duplicate")
|
|
227
|
+
|
|
228
|
+
# Assign index
|
|
229
|
+
count = await self._store.get_event_count(room_id)
|
|
230
|
+
event = event.model_copy(update={"index": count})
|
|
231
|
+
|
|
232
|
+
# Run sync hooks (before_broadcast)
|
|
233
|
+
sync_result = await self._hook_engine.run_sync_hooks(
|
|
234
|
+
room_id, HookTrigger.BEFORE_BROADCAST, event, context
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Emit framework events for any hook errors
|
|
238
|
+
for hook_err in sync_result.hook_errors:
|
|
239
|
+
await self._emit_framework_event(
|
|
240
|
+
"hook_error",
|
|
241
|
+
room_id=room_id,
|
|
242
|
+
event_id=event.id,
|
|
243
|
+
data=hook_err,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if not sync_result.allowed:
|
|
247
|
+
# RFC §4.2: Store original event as BLOCKED with audit trail
|
|
248
|
+
blocked_event = event.model_copy(
|
|
249
|
+
update={
|
|
250
|
+
"status": EventStatus.BLOCKED,
|
|
251
|
+
"blocked_by": sync_result.blocked_by or sync_result.reason,
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
await self._store.add_event(blocked_event)
|
|
255
|
+
|
|
256
|
+
await self._emit_framework_event(
|
|
257
|
+
"event_blocked",
|
|
258
|
+
room_id=room_id,
|
|
259
|
+
event_id=event.id,
|
|
260
|
+
data={
|
|
261
|
+
"reason": sync_result.reason,
|
|
262
|
+
"blocked_by": sync_result.blocked_by,
|
|
263
|
+
},
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# RFC §4.2: Deliver injected events to their target channels
|
|
267
|
+
await self._deliver_injected_events(sync_result.injected_events, room_id, context)
|
|
268
|
+
|
|
269
|
+
# Persist side effects from hooks even on blocked path
|
|
270
|
+
await self._persist_side_effects(
|
|
271
|
+
room_id,
|
|
272
|
+
sync_result.tasks,
|
|
273
|
+
sync_result.observations,
|
|
274
|
+
blocked_event,
|
|
275
|
+
context,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
return InboundResult(event=blocked_event, blocked=True, reason=sync_result.reason)
|
|
279
|
+
|
|
280
|
+
# Use potentially modified event
|
|
281
|
+
event = sync_result.event or event
|
|
282
|
+
|
|
283
|
+
# Store event as DELIVERED
|
|
284
|
+
event = event.model_copy(update={"status": EventStatus.DELIVERED})
|
|
285
|
+
await self._store.add_event(event)
|
|
286
|
+
|
|
287
|
+
# Deliver any injected events from allow/modify hooks
|
|
288
|
+
if sync_result.injected_events:
|
|
289
|
+
await self._deliver_injected_events(sync_result.injected_events, room_id, context)
|
|
290
|
+
|
|
291
|
+
# Get source binding for broadcast
|
|
292
|
+
source_binding = await self._store.get_binding(room_id, event.source.channel_id)
|
|
293
|
+
if source_binding is None:
|
|
294
|
+
return InboundResult(event=event)
|
|
295
|
+
|
|
296
|
+
# Refresh context with the new event
|
|
297
|
+
context = await self._build_context(room_id)
|
|
298
|
+
|
|
299
|
+
# Broadcast to other channels
|
|
300
|
+
router = self._get_router() # type: ignore[attr-defined]
|
|
301
|
+
broadcast_result = await router.broadcast(event, source_binding, context)
|
|
302
|
+
|
|
303
|
+
# H8: Warn on partial broadcast failure
|
|
304
|
+
if broadcast_result.errors:
|
|
305
|
+
total = len(broadcast_result.delivery_outputs) + len(broadcast_result.errors)
|
|
306
|
+
logger.warning(
|
|
307
|
+
"Partial broadcast failure: %d/%d channels failed",
|
|
308
|
+
len(broadcast_result.errors),
|
|
309
|
+
total,
|
|
310
|
+
extra={
|
|
311
|
+
"room_id": room_id,
|
|
312
|
+
"event_id": event.id,
|
|
313
|
+
"failed_channels": list(broadcast_result.errors.keys()),
|
|
314
|
+
},
|
|
315
|
+
)
|
|
316
|
+
await self._emit_framework_event(
|
|
317
|
+
"broadcast_partial_failure",
|
|
318
|
+
room_id=room_id,
|
|
319
|
+
event_id=event.id,
|
|
320
|
+
data={
|
|
321
|
+
"failed": len(broadcast_result.errors),
|
|
322
|
+
"total": total,
|
|
323
|
+
"errors": broadcast_result.errors,
|
|
324
|
+
},
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Emit delivery tracking framework events
|
|
328
|
+
for ch_id in broadcast_result.delivery_outputs:
|
|
329
|
+
await self._emit_framework_event(
|
|
330
|
+
"delivery_succeeded",
|
|
331
|
+
room_id=room_id,
|
|
332
|
+
event_id=event.id,
|
|
333
|
+
channel_id=ch_id,
|
|
334
|
+
)
|
|
335
|
+
for ch_id, error_msg in broadcast_result.errors.items():
|
|
336
|
+
await self._emit_framework_event(
|
|
337
|
+
"delivery_failed",
|
|
338
|
+
room_id=room_id,
|
|
339
|
+
event_id=event.id,
|
|
340
|
+
channel_id=ch_id,
|
|
341
|
+
data={"error": error_msg},
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Store blocked events from chain depth enforcement
|
|
345
|
+
for blocked in broadcast_result.blocked_events:
|
|
346
|
+
await self._store.add_event(blocked)
|
|
347
|
+
await self._emit_framework_event(
|
|
348
|
+
"chain_depth_exceeded",
|
|
349
|
+
room_id=room_id,
|
|
350
|
+
event_id=blocked.id,
|
|
351
|
+
channel_id=blocked.source.channel_id,
|
|
352
|
+
data={
|
|
353
|
+
"chain_depth": blocked.chain_depth,
|
|
354
|
+
"max_chain_depth": self._max_chain_depth,
|
|
355
|
+
},
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# Store reentry events and re-broadcast them (drain loop)
|
|
359
|
+
pending_reentries = deque(broadcast_result.reentry_events)
|
|
360
|
+
while pending_reentries:
|
|
361
|
+
reentry = pending_reentries.popleft()
|
|
362
|
+
await self._store.add_event(reentry)
|
|
363
|
+
reentry_binding = await self._store.get_binding(room_id, reentry.source.channel_id)
|
|
364
|
+
if reentry_binding:
|
|
365
|
+
reentry_ctx = await self._build_context(room_id)
|
|
366
|
+
reentry_result = await router.broadcast(reentry, reentry_binding, reentry_ctx)
|
|
367
|
+
# Store reentry's blocked events
|
|
368
|
+
for blocked in reentry_result.blocked_events:
|
|
369
|
+
await self._store.add_event(blocked)
|
|
370
|
+
# Queue nested reentry events for further broadcasting
|
|
371
|
+
pending_reentries.extend(reentry_result.reentry_events)
|
|
372
|
+
|
|
373
|
+
# Persist side effects from hooks and broadcast
|
|
374
|
+
all_tasks = sync_result.tasks + broadcast_result.tasks
|
|
375
|
+
all_observations = sync_result.observations + broadcast_result.observations
|
|
376
|
+
await self._persist_side_effects(
|
|
377
|
+
room_id,
|
|
378
|
+
all_tasks,
|
|
379
|
+
all_observations,
|
|
380
|
+
event,
|
|
381
|
+
context,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# Run async hooks (after_broadcast)
|
|
385
|
+
await self._hook_engine.run_async_hooks(
|
|
386
|
+
room_id, HookTrigger.AFTER_BROADCAST, event, context
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Update room latest_index and activity timestamp
|
|
390
|
+
room = await self._store.get_room(room_id)
|
|
391
|
+
if room is not None:
|
|
392
|
+
if room.timers:
|
|
393
|
+
updated_timers = room.timers.model_copy(
|
|
394
|
+
update={"last_activity_at": datetime.now(UTC)}
|
|
395
|
+
)
|
|
396
|
+
room = room.model_copy(
|
|
397
|
+
update={"latest_index": event.index, "timers": updated_timers}
|
|
398
|
+
)
|
|
399
|
+
else:
|
|
400
|
+
room = room.model_copy(update={"latest_index": event.index})
|
|
401
|
+
await self._store.update_room(room)
|
|
402
|
+
|
|
403
|
+
await self._emit_framework_event("event_processed", room_id=room_id, event_id=event.id)
|
|
404
|
+
|
|
405
|
+
return InboundResult(event=event)
|
|
406
|
+
|
|
407
|
+
async def _deliver_injected_events(
|
|
408
|
+
self,
|
|
409
|
+
injected_events: list[InjectedEvent],
|
|
410
|
+
room_id: str,
|
|
411
|
+
context: RoomContext,
|
|
412
|
+
) -> None:
|
|
413
|
+
"""Store and deliver injected events to their target channels."""
|
|
414
|
+
for injected in injected_events:
|
|
415
|
+
# Store the injected event
|
|
416
|
+
await self._store.add_event(injected.event)
|
|
417
|
+
|
|
418
|
+
# Deliver to target channels
|
|
419
|
+
target_ids = injected.target_channel_ids
|
|
420
|
+
if target_ids is None:
|
|
421
|
+
# No target specified — skip delivery (stored only)
|
|
422
|
+
continue
|
|
423
|
+
|
|
424
|
+
for target_id in target_ids:
|
|
425
|
+
channel = self._channels.get(target_id)
|
|
426
|
+
binding = await self._store.get_binding(room_id, target_id)
|
|
427
|
+
if channel is not None and binding is not None:
|
|
428
|
+
try:
|
|
429
|
+
await channel.on_event(injected.event, binding, context)
|
|
430
|
+
except Exception:
|
|
431
|
+
logger.exception(
|
|
432
|
+
"Failed to deliver injected event to %s",
|
|
433
|
+
target_id,
|
|
434
|
+
extra={"room_id": room_id, "channel_id": target_id},
|
|
435
|
+
)
|