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,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
+ )