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,401 @@
1
+ """Event routing with permission enforcement and transcoding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from roomkit.channels.base import Channel
11
+ from roomkit.core.circuit_breaker import CircuitBreaker
12
+ from roomkit.core.rate_limiter import TokenBucketRateLimiter
13
+ from roomkit.core.retry import retry_with_backoff
14
+ from roomkit.core.transcoder import DefaultContentTranscoder
15
+ from roomkit.models.channel import ChannelBinding, ChannelOutput
16
+ from roomkit.models.context import RoomContext
17
+ from roomkit.models.enums import Access, ChannelCategory, ChannelDirection, EventStatus
18
+ from roomkit.models.event import RoomEvent, TextContent
19
+ from roomkit.models.task import Observation, Task
20
+
21
+ logger = logging.getLogger("roomkit.event_router")
22
+
23
+
24
+ @dataclass
25
+ class BroadcastResult:
26
+ """Result of broadcasting an event to channels."""
27
+
28
+ outputs: dict[str, ChannelOutput] = field(default_factory=dict)
29
+ delivery_outputs: dict[str, ChannelOutput] = field(default_factory=dict)
30
+ reentry_events: list[RoomEvent] = field(default_factory=list)
31
+ tasks: list[Task] = field(default_factory=list)
32
+ observations: list[Observation] = field(default_factory=list)
33
+ metadata_updates: dict[str, Any] = field(default_factory=dict)
34
+ blocked_events: list[RoomEvent] = field(default_factory=list)
35
+ errors: dict[str, str] = field(default_factory=dict)
36
+
37
+
38
+ @dataclass
39
+ class _TargetResult:
40
+ """Per-target result collected during concurrent broadcast."""
41
+
42
+ channel_id: str
43
+ output: ChannelOutput | None = None
44
+ delivery_output: ChannelOutput | None = None
45
+ error: str | None = None
46
+ reentry_events: list[RoomEvent] = field(default_factory=list)
47
+ blocked_events: list[RoomEvent] = field(default_factory=list)
48
+ observations: list[Observation] = field(default_factory=list)
49
+
50
+
51
+ class EventRouter:
52
+ """Routes events to target channels with access control and transcoding."""
53
+
54
+ def __init__(
55
+ self,
56
+ channels: dict[str, Channel],
57
+ transcoder: DefaultContentTranscoder | None = None,
58
+ max_chain_depth: int = 5,
59
+ rate_limiter: TokenBucketRateLimiter | None = None,
60
+ ) -> None:
61
+ self._channels = channels
62
+ self._transcoder = transcoder or DefaultContentTranscoder()
63
+ self._max_chain_depth = max_chain_depth
64
+ self._rate_limiter = rate_limiter or TokenBucketRateLimiter()
65
+ self._circuit_breakers: dict[str, CircuitBreaker] = {}
66
+
67
+ def _get_breaker(self, channel_id: str) -> CircuitBreaker:
68
+ """Get or create a circuit breaker for a channel."""
69
+ if channel_id not in self._circuit_breakers:
70
+ self._circuit_breakers[channel_id] = CircuitBreaker()
71
+ return self._circuit_breakers[channel_id]
72
+
73
+ async def broadcast(
74
+ self,
75
+ event: RoomEvent,
76
+ source_binding: ChannelBinding,
77
+ context: RoomContext,
78
+ ) -> BroadcastResult:
79
+ """Broadcast an event to all eligible channels in the room.
80
+
81
+ RFC §3.8: For each target channel:
82
+ - on_event(): all channels react (intelligence generates, observers analyze)
83
+ - deliver(): only transport channels push to external recipients
84
+ """
85
+ result = BroadcastResult()
86
+
87
+ # Check source can write
88
+ if source_binding.access in (Access.READ_ONLY, Access.NONE):
89
+ logger.debug(
90
+ "Source %s has no write access",
91
+ source_binding.channel_id,
92
+ extra={"room_id": event.room_id, "channel_id": source_binding.channel_id},
93
+ )
94
+ return result
95
+
96
+ # Check source is not muted
97
+ if source_binding.muted:
98
+ logger.debug(
99
+ "Source %s is muted",
100
+ source_binding.channel_id,
101
+ extra={"room_id": event.room_id, "channel_id": source_binding.channel_id},
102
+ )
103
+ return result
104
+
105
+ # Stamp visibility from source binding
106
+ if event.visibility == "all" and source_binding.visibility != "all":
107
+ event = event.model_copy(update={"visibility": source_binding.visibility})
108
+
109
+ # Determine target bindings (includes muted channels — they can still read)
110
+ targets = self._filter_targets(event, source_binding, context.bindings)
111
+
112
+ if not targets:
113
+ return result
114
+
115
+ # Collect per-target results to avoid concurrent mutation
116
+ target_results: list[_TargetResult] = []
117
+
118
+ async def _process_target(binding: ChannelBinding) -> None:
119
+ channel = self._channels.get(binding.channel_id)
120
+ if channel is None:
121
+ logger.warning(
122
+ "Channel %s not found in registry, skipping. Available: %s",
123
+ binding.channel_id,
124
+ list(self._channels.keys()),
125
+ )
126
+ return
127
+
128
+ tr = _TargetResult(channel_id=binding.channel_id)
129
+
130
+ try:
131
+ # Transcode content if needed
132
+ transcoded_event = await self._maybe_transcode(event, source_binding, binding)
133
+ if transcoded_event is None:
134
+ tr.error = "transcoding_failed"
135
+ logger.warning(
136
+ "Transcoding failed for channel %s — skipping delivery",
137
+ binding.channel_id,
138
+ extra={
139
+ "room_id": event.room_id,
140
+ "channel_id": binding.channel_id,
141
+ "event_id": event.id,
142
+ },
143
+ )
144
+ target_results.append(tr)
145
+ return
146
+
147
+ # Enforce max_length on text content
148
+ if binding.capabilities.max_length is not None:
149
+ transcoded_event = self._enforce_max_length(
150
+ transcoded_event, binding.capabilities.max_length
151
+ )
152
+
153
+ # Step 1: on_event — all channels react
154
+ output = await channel.on_event(transcoded_event, binding, context)
155
+ tr.output = output
156
+
157
+ # Step 2: deliver — only transport channels push to external
158
+ if binding.category == ChannelCategory.TRANSPORT:
159
+ breaker = self._get_breaker(binding.channel_id)
160
+
161
+ if not breaker.allow_request():
162
+ tr.error = "circuit_breaker_open"
163
+ logger.warning(
164
+ "Circuit breaker open for %s — skipping delivery",
165
+ binding.channel_id,
166
+ extra={
167
+ "room_id": transcoded_event.room_id,
168
+ "channel_id": binding.channel_id,
169
+ },
170
+ )
171
+ else:
172
+ # Rate limit
173
+ if binding.rate_limit is not None:
174
+ await self._rate_limiter.wait(binding.channel_id, binding.rate_limit)
175
+
176
+ try:
177
+ if binding.retry_policy is not None:
178
+ delivery_output = await retry_with_backoff(
179
+ channel.deliver,
180
+ binding.retry_policy,
181
+ transcoded_event,
182
+ binding,
183
+ context,
184
+ )
185
+ else:
186
+ delivery_output = await channel.deliver(
187
+ transcoded_event, binding, context
188
+ )
189
+ tr.delivery_output = delivery_output
190
+ breaker.record_success()
191
+ except Exception as exc:
192
+ breaker.record_failure()
193
+ tr.error = str(exc)
194
+ logger.exception(
195
+ "Delivery to %s failed",
196
+ binding.channel_id,
197
+ extra={
198
+ "room_id": event.room_id,
199
+ "channel_id": binding.channel_id,
200
+ "event_id": event.id,
201
+ },
202
+ )
203
+
204
+ # Always collect side effects (tasks, observations, metadata)
205
+ # regardless of mute status — RFC: "muting silences the voice,
206
+ # not the brain"
207
+ tr.observations.extend(output.observations)
208
+
209
+ # Muted channels: suppress response_events (the "voice")
210
+ if binding.muted:
211
+ if output.responded:
212
+ logger.debug(
213
+ "Channel %s is muted — suppressing %d response events, "
214
+ "keeping %d tasks, %d observations",
215
+ binding.channel_id,
216
+ len(output.response_events),
217
+ len(output.tasks),
218
+ len(output.observations),
219
+ )
220
+ target_results.append(tr)
221
+ return
222
+
223
+ # Collect reentry events with chain depth enforcement
224
+ if output.responded:
225
+ for resp in output.response_events:
226
+ if resp.chain_depth < self._max_chain_depth:
227
+ tr.reentry_events.append(resp)
228
+ else:
229
+ blocked = resp.model_copy(
230
+ update={
231
+ "status": EventStatus.BLOCKED,
232
+ "blocked_by": "event_chain_depth_limit",
233
+ }
234
+ )
235
+ tr.blocked_events.append(blocked)
236
+ tr.observations.append(
237
+ Observation(
238
+ id=f"obs_{blocked.id}",
239
+ room_id=event.room_id,
240
+ channel_id=binding.channel_id,
241
+ content=(
242
+ f"Event chain depth {resp.chain_depth}"
243
+ f" exceeded limit {self._max_chain_depth}"
244
+ ),
245
+ category="event_chain_depth_exceeded",
246
+ metadata={
247
+ "chain_depth": resp.chain_depth,
248
+ "max_chain_depth": self._max_chain_depth,
249
+ "source_channel": binding.channel_id,
250
+ },
251
+ )
252
+ )
253
+ logger.warning(
254
+ "Chain depth %d exceeded limit %d for channel %s — event blocked",
255
+ resp.chain_depth,
256
+ self._max_chain_depth,
257
+ binding.channel_id,
258
+ extra={
259
+ "room_id": event.room_id,
260
+ "channel_id": binding.channel_id,
261
+ "chain_depth": resp.chain_depth,
262
+ },
263
+ )
264
+
265
+ except Exception as exc:
266
+ tr.error = str(exc)
267
+ logger.exception(
268
+ "Processing target %s failed",
269
+ binding.channel_id,
270
+ extra={
271
+ "room_id": event.room_id,
272
+ "channel_id": binding.channel_id,
273
+ "event_id": event.id,
274
+ },
275
+ )
276
+
277
+ target_results.append(tr)
278
+
279
+ await asyncio.gather(*[_process_target(t) for t in targets], return_exceptions=True)
280
+
281
+ # Merge per-target results into BroadcastResult (single-threaded)
282
+ for tr in target_results:
283
+ if tr.output is not None:
284
+ result.outputs[tr.channel_id] = tr.output
285
+ result.tasks.extend(tr.output.tasks)
286
+ result.metadata_updates.update(tr.output.metadata_updates)
287
+ if tr.delivery_output is not None:
288
+ result.delivery_outputs[tr.channel_id] = tr.delivery_output
289
+ if tr.error is not None:
290
+ result.errors[tr.channel_id] = tr.error
291
+ result.reentry_events.extend(tr.reentry_events)
292
+ result.blocked_events.extend(tr.blocked_events)
293
+ result.observations.extend(tr.observations)
294
+
295
+ return result
296
+
297
+ def _filter_targets(
298
+ self,
299
+ event: RoomEvent,
300
+ source_binding: ChannelBinding,
301
+ all_bindings: list[ChannelBinding],
302
+ ) -> list[ChannelBinding]:
303
+ """Filter bindings to find valid delivery targets.
304
+
305
+ Muted channels ARE included — they can still receive events via on_event()
306
+ and produce side effects (tasks, observations). Their response_events are
307
+ suppressed in broadcast().
308
+ """
309
+ targets: list[ChannelBinding] = []
310
+
311
+ for binding in all_bindings:
312
+ # Skip source channel
313
+ if binding.channel_id == source_binding.channel_id:
314
+ continue
315
+
316
+ # Check access - must be able to read
317
+ if binding.access in (Access.WRITE_ONLY, Access.NONE):
318
+ continue
319
+
320
+ # Check direction - must accept inbound delivery
321
+ if binding.direction == ChannelDirection.OUTBOUND:
322
+ continue
323
+
324
+ # NOTE: muted channels are NOT skipped here — they receive events
325
+ # but their response_events are suppressed in broadcast()
326
+
327
+ # Check visibility
328
+ if not self._check_visibility(event, source_binding, binding):
329
+ continue
330
+
331
+ targets.append(binding)
332
+
333
+ return targets
334
+
335
+ def _check_visibility(
336
+ self,
337
+ event: RoomEvent,
338
+ source_binding: ChannelBinding,
339
+ target_binding: ChannelBinding,
340
+ ) -> bool:
341
+ """Check if source is visible to target based on visibility rules."""
342
+ vis = source_binding.visibility
343
+
344
+ if vis == "all":
345
+ return True
346
+
347
+ if vis == "none":
348
+ return False
349
+
350
+ # "transport" - only visible to transport channels
351
+ if vis == "transport":
352
+ return target_binding.category == ChannelCategory.TRANSPORT
353
+
354
+ # "intelligence" - only visible to intelligence channels
355
+ if vis == "intelligence":
356
+ return target_binding.category == ChannelCategory.INTELLIGENCE
357
+
358
+ # Comma-separated list of channel IDs
359
+ if "," in vis:
360
+ allowed = {cid.strip() for cid in vis.split(",")}
361
+ return target_binding.channel_id in allowed
362
+
363
+ # Single channel ID
364
+ return target_binding.channel_id == vis
365
+
366
+ async def _maybe_transcode(
367
+ self,
368
+ event: RoomEvent,
369
+ source_binding: ChannelBinding,
370
+ target_binding: ChannelBinding,
371
+ ) -> RoomEvent | None:
372
+ """Transcode event content if the target doesn't support it.
373
+
374
+ Returns ``None`` if the content cannot be transcoded for the target.
375
+ """
376
+ source_types = set(source_binding.capabilities.media_types)
377
+ target_types = set(target_binding.capabilities.media_types)
378
+
379
+ if source_types <= target_types:
380
+ return event
381
+
382
+ transcoded_content = await self._transcoder.transcode(
383
+ event.content, source_binding, target_binding
384
+ )
385
+ if transcoded_content is None:
386
+ return None
387
+ if transcoded_content is event.content:
388
+ return event
389
+
390
+ return event.model_copy(update={"content": transcoded_content})
391
+
392
+ @staticmethod
393
+ def _enforce_max_length(event: RoomEvent, max_length: int) -> RoomEvent:
394
+ """Truncate text content if it exceeds the channel's max_length."""
395
+ content = event.content
396
+ if isinstance(content, TextContent) and len(content.body) > max_length:
397
+ truncated = content.body[: max_length - 3] + "..."
398
+ return event.model_copy(
399
+ update={"content": TextContent(body=truncated, language=content.language)}
400
+ )
401
+ return event