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,793 @@
1
+ """RoomKit - central orchestrator for multi-channel conversations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import inspect
8
+ import logging
9
+ from collections.abc import Awaitable, Callable
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from roomkit.models.delivery import InboundMessage, InboundResult
14
+ from roomkit.providers.sms.meta import WebhookMeta
15
+
16
+ from roomkit.channels.base import Channel
17
+ from roomkit.channels.websocket import SendFn, WebSocketChannel
18
+ from roomkit.core._channel_ops import ChannelOpsMixin
19
+ from roomkit.core._helpers import FrameworkEventHandler, HelpersMixin, IdentityHookFn
20
+ from roomkit.core._inbound import InboundMixin
21
+ from roomkit.core._room_lifecycle import RoomLifecycleMixin
22
+ from roomkit.core.event_router import EventRouter
23
+ from roomkit.core.hooks import (
24
+ AsyncHookFn,
25
+ HookEngine,
26
+ HookRegistration,
27
+ IdentityHookRegistration,
28
+ SyncHookFn,
29
+ )
30
+ from roomkit.core.inbound_router import DefaultInboundRoomRouter, InboundRoomRouter
31
+ from roomkit.core.locks import InMemoryLockManager, RoomLockManager
32
+ from roomkit.core.transcoder import DefaultContentTranscoder
33
+ from roomkit.identity.base import IdentityResolver
34
+ from roomkit.models.delivery import DeliveryStatus
35
+ from roomkit.models.enums import (
36
+ ChannelDirection,
37
+ ChannelType,
38
+ EventStatus,
39
+ EventType,
40
+ HookExecution,
41
+ HookTrigger,
42
+ )
43
+ from roomkit.models.event import EventSource, RoomEvent
44
+ from roomkit.models.task import Observation, Task
45
+ from roomkit.realtime.base import (
46
+ EphemeralCallback,
47
+ EphemeralEvent,
48
+ EphemeralEventType,
49
+ RealtimeBackend,
50
+ )
51
+ from roomkit.realtime.memory import InMemoryRealtime
52
+ from roomkit.sources.base import SourceHealth, SourceProvider, SourceStatus
53
+ from roomkit.store.base import ConversationStore
54
+ from roomkit.store.memory import InMemoryStore
55
+
56
+ # Type alias for delivery status handlers
57
+ DeliveryStatusHandler = Callable[[DeliveryStatus], Any]
58
+
59
+ # Re-export type aliases so existing imports continue to work
60
+ __all__ = [
61
+ "ChannelNotFoundError",
62
+ "ChannelNotRegisteredError",
63
+ "FrameworkEventHandler",
64
+ "IdentityHookFn",
65
+ "IdentityNotFoundError",
66
+ "ParticipantNotFoundError",
67
+ "RoomKit",
68
+ "RoomKitError",
69
+ "RoomNotFoundError",
70
+ "SourceAlreadyAttachedError",
71
+ "SourceNotFoundError",
72
+ ]
73
+
74
+
75
+ class RoomKitError(Exception):
76
+ """Base exception for all RoomKit errors."""
77
+
78
+
79
+ class RoomNotFoundError(RoomKitError):
80
+ """Room does not exist."""
81
+
82
+
83
+ class ChannelNotFoundError(RoomKitError):
84
+ """Channel binding not found in room."""
85
+
86
+
87
+ class ChannelNotRegisteredError(RoomKitError):
88
+ """Channel type not registered."""
89
+
90
+
91
+ class ParticipantNotFoundError(RoomKitError):
92
+ """Participant not found in room."""
93
+
94
+
95
+ class IdentityNotFoundError(RoomKitError):
96
+ """Identity not found."""
97
+
98
+
99
+ class SourceAlreadyAttachedError(RoomKitError):
100
+ """Source already attached to channel."""
101
+
102
+
103
+ class SourceNotFoundError(RoomKitError):
104
+ """Source not found for channel."""
105
+
106
+
107
+ class RoomKit(InboundMixin, ChannelOpsMixin, RoomLifecycleMixin, HelpersMixin):
108
+ """Central orchestrator tying rooms, channels, hooks, and storage."""
109
+
110
+ def __init__(
111
+ self,
112
+ store: ConversationStore | None = None,
113
+ identity_resolver: IdentityResolver | None = None,
114
+ identity_channel_types: set[ChannelType] | None = None,
115
+ inbound_router: InboundRoomRouter | None = None,
116
+ lock_manager: RoomLockManager | None = None,
117
+ realtime: RealtimeBackend | None = None,
118
+ max_chain_depth: int = 5,
119
+ identity_timeout: float = 10.0,
120
+ process_timeout: float = 30.0,
121
+ ) -> None:
122
+ """Initialise the RoomKit orchestrator.
123
+
124
+ Args:
125
+ store: Persistent storage backend. Defaults to ``InMemoryStore``.
126
+ identity_resolver: Optional resolver for identifying inbound senders.
127
+ identity_channel_types: Restrict identity resolution to specific channel
128
+ types. If ``None`` (default), resolution runs for all channels.
129
+ Set to e.g. ``{ChannelType.SMS}`` to only resolve identity for SMS.
130
+ inbound_router: Strategy for routing inbound messages to rooms.
131
+ Defaults to ``DefaultInboundRoomRouter``.
132
+ lock_manager: Per-room locking backend. Defaults to
133
+ ``InMemoryLockManager``. For multi-process deployments,
134
+ supply a distributed implementation (e.g. Redis-backed).
135
+ realtime: Realtime backend for ephemeral events (typing, presence).
136
+ Defaults to ``InMemoryRealtime``. For multi-process deployments,
137
+ supply a distributed implementation (e.g. Redis pub/sub).
138
+ max_chain_depth: Maximum reentry chain depth to prevent infinite loops.
139
+ identity_timeout: Timeout in seconds for identity resolution calls.
140
+ process_timeout: Timeout in seconds for the locked processing phase.
141
+ """
142
+ self._store = store or InMemoryStore()
143
+ self._identity_resolver = identity_resolver
144
+ self._identity_channel_types = identity_channel_types
145
+ self._max_chain_depth = max_chain_depth
146
+ self._identity_timeout = identity_timeout
147
+ self._process_timeout = process_timeout
148
+ self._channels: dict[str, Channel] = {}
149
+ self._hook_engine = HookEngine()
150
+ self._lock_manager = lock_manager or InMemoryLockManager()
151
+ self._realtime = realtime or InMemoryRealtime()
152
+ self._transcoder = DefaultContentTranscoder()
153
+ self._event_handlers: list[tuple[str, FrameworkEventHandler]] = []
154
+ self._identity_hooks: dict[HookTrigger, list[IdentityHookRegistration]] = {}
155
+ self._delivery_status_handlers: list[DeliveryStatusHandler] = []
156
+ self._inbound_router = inbound_router or DefaultInboundRoomRouter(self._store)
157
+ self._event_router: EventRouter | None = None
158
+ # Event-driven sources
159
+ self._sources: dict[str, SourceProvider] = {}
160
+ self._source_tasks: dict[str, asyncio.Task[None]] = {}
161
+
162
+ @property
163
+ def store(self) -> ConversationStore:
164
+ """The backing conversation store."""
165
+ return self._store
166
+
167
+ @property
168
+ def hook_engine(self) -> HookEngine:
169
+ """The hook engine used for sync/async hook pipelines."""
170
+ return self._hook_engine
171
+
172
+ @property
173
+ def realtime(self) -> RealtimeBackend:
174
+ """The realtime backend for ephemeral events."""
175
+ return self._realtime
176
+
177
+ def _get_router(self) -> EventRouter:
178
+ if self._event_router is None:
179
+ self._event_router = EventRouter(
180
+ channels=self._channels,
181
+ transcoder=self._transcoder,
182
+ max_chain_depth=self._max_chain_depth,
183
+ )
184
+ return self._event_router
185
+
186
+ async def close(self) -> None:
187
+ """Close all sources, channels, and the realtime backend."""
188
+ # Stop all event sources first
189
+ for channel_id in list(self._sources.keys()):
190
+ await self.detach_source(channel_id)
191
+ # Then close channels
192
+ for channel in self._channels.values():
193
+ await channel.close()
194
+ await self._realtime.close()
195
+
196
+ async def __aenter__(self) -> RoomKit:
197
+ return self
198
+
199
+ async def __aexit__(self, *exc: object) -> None:
200
+ await self.close()
201
+
202
+ # -- Queries --
203
+
204
+ async def get_timeline(
205
+ self,
206
+ room_id: str,
207
+ offset: int = 0,
208
+ limit: int = 50,
209
+ visibility_filter: str | None = None,
210
+ ) -> list[RoomEvent]:
211
+ """Query the event timeline for a room."""
212
+ await self.get_room(room_id)
213
+ return await self._store.list_events(
214
+ room_id,
215
+ offset=offset,
216
+ limit=limit,
217
+ visibility_filter=visibility_filter,
218
+ )
219
+
220
+ async def list_tasks(self, room_id: str, status: str | None = None) -> list[Task]:
221
+ """List tasks for a room, optionally filtered by status."""
222
+ return await self._store.list_tasks(room_id, status=status)
223
+
224
+ async def list_observations(self, room_id: str) -> list[Observation]:
225
+ """List observations for a room."""
226
+ return await self._store.list_observations(room_id)
227
+
228
+ # -- Direct send --
229
+
230
+ async def send_event(
231
+ self,
232
+ room_id: str,
233
+ channel_id: str,
234
+ content: Any,
235
+ event_type: EventType = EventType.MESSAGE,
236
+ chain_depth: int = 0,
237
+ participant_id: str | None = None,
238
+ metadata: dict[str, Any] | None = None,
239
+ visibility: str = "all",
240
+ ) -> RoomEvent:
241
+ """Send an event directly into a room from a channel.
242
+
243
+ Args:
244
+ room_id: Target room ID
245
+ channel_id: Source channel ID
246
+ content: Event content (TextContent, RichContent, etc.)
247
+ event_type: Type of event (default MESSAGE)
248
+ chain_depth: Depth in response chain (for loop prevention)
249
+ participant_id: Optional participant/sender ID for the event source
250
+ metadata: Optional event metadata
251
+ visibility: Event visibility ("all" or "internal")
252
+ """
253
+ await self.get_room(room_id)
254
+ binding = await self._get_binding(room_id, channel_id)
255
+
256
+ event = RoomEvent(
257
+ room_id=room_id,
258
+ type=event_type,
259
+ source=EventSource(
260
+ channel_id=channel_id,
261
+ channel_type=binding.channel_type,
262
+ participant_id=participant_id,
263
+ ),
264
+ content=content,
265
+ chain_depth=chain_depth,
266
+ status=EventStatus.DELIVERED,
267
+ metadata=metadata or {},
268
+ visibility=visibility,
269
+ )
270
+
271
+ async with self._lock_manager.locked(room_id):
272
+ count = await self._store.get_event_count(room_id)
273
+ event = event.model_copy(update={"index": count})
274
+ await self._store.add_event(event)
275
+
276
+ context = await self._build_context(room_id)
277
+ router = self._get_router()
278
+ await router.broadcast(event, binding, context)
279
+
280
+ return event
281
+
282
+ # -- WebSocket lifecycle --
283
+
284
+ async def connect_websocket(
285
+ self, channel_id: str, connection_id: str, send_fn: SendFn
286
+ ) -> None:
287
+ """Register a WebSocket connection and emit framework event."""
288
+ channel = self._channels.get(channel_id)
289
+ if not isinstance(channel, WebSocketChannel):
290
+ raise ChannelNotRegisteredError(
291
+ f"Channel {channel_id} is not a registered WebSocket channel"
292
+ )
293
+ channel.register_connection(connection_id, send_fn)
294
+ await self._emit_framework_event(
295
+ "channel_connected",
296
+ channel_id=channel_id,
297
+ data={"connection_id": connection_id},
298
+ )
299
+
300
+ async def disconnect_websocket(self, channel_id: str, connection_id: str) -> None:
301
+ """Unregister a WebSocket connection and emit framework event."""
302
+ channel = self._channels.get(channel_id)
303
+ if isinstance(channel, WebSocketChannel):
304
+ channel.unregister_connection(connection_id)
305
+ await self._emit_framework_event(
306
+ "channel_disconnected",
307
+ channel_id=channel_id,
308
+ data={"connection_id": connection_id},
309
+ )
310
+
311
+ # -- Read tracking --
312
+
313
+ async def mark_read(self, room_id: str, channel_id: str, event_id: str) -> None:
314
+ """Mark an event as read for a channel."""
315
+ await self._store.mark_read(room_id, channel_id, event_id)
316
+
317
+ async def mark_all_read(self, room_id: str, channel_id: str) -> None:
318
+ """Mark all events as read for a channel."""
319
+ await self._store.mark_all_read(room_id, channel_id)
320
+
321
+ # -- Realtime (ephemeral events) --
322
+
323
+ async def publish_typing(
324
+ self,
325
+ room_id: str,
326
+ user_id: str,
327
+ is_typing: bool = True,
328
+ data: dict[str, Any] | None = None,
329
+ ) -> None:
330
+ """Publish a typing indicator for a user in a room.
331
+
332
+ Args:
333
+ room_id: The room to publish the typing event in.
334
+ user_id: The user who is typing.
335
+ is_typing: True for typing_start, False for typing_stop.
336
+ data: Optional additional data (e.g., {"name": "User Name"}).
337
+ """
338
+ event = EphemeralEvent(
339
+ room_id=room_id,
340
+ type=EphemeralEventType.TYPING_START if is_typing else EphemeralEventType.TYPING_STOP,
341
+ user_id=user_id,
342
+ data=data or {},
343
+ )
344
+ await self._realtime.publish_to_room(room_id, event)
345
+
346
+ async def publish_presence(self, room_id: str, user_id: str, status: str) -> None:
347
+ """Publish a presence update for a user in a room.
348
+
349
+ Args:
350
+ room_id: The room to publish the presence event in.
351
+ user_id: The user whose presence changed.
352
+ status: One of "online", "away", or "offline".
353
+ """
354
+ type_map = {
355
+ "online": EphemeralEventType.PRESENCE_ONLINE,
356
+ "away": EphemeralEventType.PRESENCE_AWAY,
357
+ "offline": EphemeralEventType.PRESENCE_OFFLINE,
358
+ }
359
+ event_type = type_map.get(status, EphemeralEventType.CUSTOM)
360
+ event = EphemeralEvent(
361
+ room_id=room_id,
362
+ type=event_type,
363
+ user_id=user_id,
364
+ data={"status": status} if event_type == EphemeralEventType.CUSTOM else {},
365
+ )
366
+ await self._realtime.publish_to_room(room_id, event)
367
+
368
+ async def publish_read_receipt(self, room_id: str, user_id: str, event_id: str) -> None:
369
+ """Publish a read receipt for a user in a room.
370
+
371
+ Args:
372
+ room_id: The room to publish the read receipt in.
373
+ user_id: The user who read the message.
374
+ event_id: The ID of the event that was read.
375
+ """
376
+ event = EphemeralEvent(
377
+ room_id=room_id,
378
+ type=EphemeralEventType.READ_RECEIPT,
379
+ user_id=user_id,
380
+ data={"event_id": event_id},
381
+ )
382
+ await self._realtime.publish_to_room(room_id, event)
383
+
384
+ async def subscribe_room(self, room_id: str, callback: EphemeralCallback) -> str:
385
+ """Subscribe to ephemeral events for a room.
386
+
387
+ Args:
388
+ room_id: The room to subscribe to.
389
+ callback: Async callback invoked for each ephemeral event.
390
+
391
+ Returns:
392
+ A subscription ID that can be used to unsubscribe.
393
+ """
394
+ return await self._realtime.subscribe_to_room(room_id, callback)
395
+
396
+ async def unsubscribe_room(self, subscription_id: str) -> bool:
397
+ """Unsubscribe from ephemeral events.
398
+
399
+ Args:
400
+ subscription_id: The subscription ID returned by subscribe_room.
401
+
402
+ Returns:
403
+ True if the subscription existed and was removed.
404
+ """
405
+ return await self._realtime.unsubscribe(subscription_id)
406
+
407
+ # -- Event-driven sources --
408
+
409
+ async def attach_source(
410
+ self,
411
+ channel_id: str,
412
+ source: SourceProvider,
413
+ *,
414
+ auto_restart: bool = True,
415
+ restart_delay: float = 5.0,
416
+ max_restart_delay: float = 300.0,
417
+ max_restart_attempts: int | None = None,
418
+ max_concurrent_emits: int | None = 10,
419
+ ) -> None:
420
+ """Attach an event-driven source to a channel.
421
+
422
+ The source will start listening for messages and emit them into
423
+ RoomKit's inbound pipeline via ``process_inbound()``.
424
+
425
+ Args:
426
+ channel_id: The channel ID to associate with this source.
427
+ Messages from this source will be tagged with this channel_id.
428
+ source: The source provider instance to attach.
429
+ auto_restart: If True (default), automatically restart the source
430
+ if it exits unexpectedly. Set to False for one-shot sources.
431
+ restart_delay: Initial delay in seconds before restarting after
432
+ failure. Doubles on each consecutive failure (exponential backoff).
433
+ max_restart_delay: Maximum delay between restart attempts in seconds.
434
+ Backoff is capped at this value. Defaults to 300 (5 minutes).
435
+ max_restart_attempts: Maximum number of consecutive restart attempts
436
+ before giving up. If None (default), retries indefinitely.
437
+ When exhausted, emits ``source_exhausted`` framework event.
438
+ max_concurrent_emits: Maximum number of concurrent ``emit()`` calls
439
+ to prevent backpressure buildup. Defaults to 10. Set to None
440
+ for unlimited concurrency (not recommended for high-volume sources).
441
+
442
+ Raises:
443
+ SourceAlreadyAttachedError: If a source is already attached to
444
+ this channel_id.
445
+
446
+ Example:
447
+ from roomkit.sources.neonize import NeonizeSource
448
+
449
+ source = NeonizeSource(session_path="~/.roomkit/wa.db")
450
+ await kit.attach_source(
451
+ "whatsapp-personal",
452
+ source,
453
+ max_restart_attempts=5, # Give up after 5 failures
454
+ max_concurrent_emits=20, # Allow 20 concurrent messages
455
+ )
456
+ """
457
+ if channel_id in self._sources:
458
+ raise SourceAlreadyAttachedError(f"Source already attached to channel {channel_id}")
459
+
460
+ logger = logging.getLogger("roomkit.sources")
461
+
462
+ # Create emit callback with optional backpressure control
463
+ if max_concurrent_emits is not None:
464
+ semaphore = asyncio.Semaphore(max_concurrent_emits)
465
+
466
+ async def emit(msg: InboundMessage) -> InboundResult:
467
+ async with semaphore:
468
+ return await self.process_inbound(msg)
469
+ else:
470
+
471
+ async def emit(msg: InboundMessage) -> InboundResult:
472
+ return await self.process_inbound(msg)
473
+
474
+ self._sources[channel_id] = source
475
+ self._source_tasks[channel_id] = asyncio.create_task(
476
+ self._run_source(
477
+ channel_id,
478
+ source,
479
+ emit,
480
+ auto_restart,
481
+ restart_delay,
482
+ max_restart_delay,
483
+ max_restart_attempts,
484
+ logger,
485
+ ),
486
+ name=f"source:{channel_id}",
487
+ )
488
+
489
+ await self._emit_framework_event(
490
+ "source_attached",
491
+ channel_id=channel_id,
492
+ data={"source_name": source.name},
493
+ )
494
+
495
+ async def detach_source(self, channel_id: str) -> None:
496
+ """Detach and stop an event-driven source.
497
+
498
+ Args:
499
+ channel_id: The channel ID of the source to detach.
500
+
501
+ Raises:
502
+ SourceNotFoundError: If no source is attached to this channel_id.
503
+ """
504
+ if channel_id not in self._sources:
505
+ raise SourceNotFoundError(f"No source attached to channel {channel_id}")
506
+
507
+ source = self._sources.pop(channel_id)
508
+ task = self._source_tasks.pop(channel_id)
509
+
510
+ # Stop the source
511
+ await source.stop()
512
+
513
+ # Cancel the runner task and await its completion
514
+ task.cancel()
515
+ with contextlib.suppress(asyncio.CancelledError, Exception):
516
+ await task
517
+
518
+ await self._emit_framework_event(
519
+ "source_detached",
520
+ channel_id=channel_id,
521
+ data={"source_name": source.name},
522
+ )
523
+
524
+ async def _run_source(
525
+ self,
526
+ channel_id: str,
527
+ source: SourceProvider,
528
+ emit: Callable[[InboundMessage], Awaitable[InboundResult]],
529
+ auto_restart: bool,
530
+ restart_delay: float,
531
+ max_restart_delay: float,
532
+ max_restart_attempts: int | None,
533
+ logger: logging.Logger,
534
+ ) -> None:
535
+ """Run a source with optional auto-restart on failure.
536
+
537
+ Uses exponential backoff: delay doubles on each failure, capped at
538
+ max_restart_delay. Delay resets after a successful start.
539
+ """
540
+
541
+ attempt = 0
542
+ current_delay = restart_delay
543
+
544
+ while True:
545
+ try:
546
+ logger.info("Starting source %s for channel %s", source.name, channel_id)
547
+ await source.start(emit)
548
+ # Clean exit - source stopped normally
549
+ logger.info("Source %s stopped cleanly", source.name)
550
+ break
551
+ except asyncio.CancelledError:
552
+ logger.debug("Source %s cancelled", source.name)
553
+ raise
554
+ except Exception as e:
555
+ attempt += 1
556
+ logger.exception("Source %s failed (attempt %d): %s", source.name, attempt, e)
557
+ await self._emit_framework_event(
558
+ "source_error",
559
+ channel_id=channel_id,
560
+ data={"source_name": source.name, "error": str(e), "attempt": attempt},
561
+ )
562
+
563
+ if not auto_restart:
564
+ raise
565
+
566
+ # Check if max attempts exceeded
567
+ if max_restart_attempts is not None and attempt >= max_restart_attempts:
568
+ logger.error(
569
+ "Source %s exhausted after %d attempts, giving up",
570
+ source.name,
571
+ attempt,
572
+ )
573
+ await self._emit_framework_event(
574
+ "source_exhausted",
575
+ channel_id=channel_id,
576
+ data={
577
+ "source_name": source.name,
578
+ "attempts": attempt,
579
+ "last_error": str(e),
580
+ },
581
+ )
582
+ break
583
+
584
+ logger.info(
585
+ "Restarting source %s in %.1f seconds (attempt %d%s)",
586
+ source.name,
587
+ current_delay,
588
+ attempt,
589
+ f"/{max_restart_attempts}" if max_restart_attempts else "",
590
+ )
591
+ await asyncio.sleep(current_delay)
592
+
593
+ # Exponential backoff: double delay, cap at max
594
+ current_delay = min(current_delay * 2, max_restart_delay)
595
+
596
+ async def source_health(self, channel_id: str) -> SourceHealth | None:
597
+ """Get health information for an attached source.
598
+
599
+ Args:
600
+ channel_id: The channel ID of the source.
601
+
602
+ Returns:
603
+ SourceHealth if a source is attached, None otherwise.
604
+ """
605
+ source = self._sources.get(channel_id)
606
+ if source is None:
607
+ return None
608
+ return await source.healthcheck()
609
+
610
+ def list_sources(self) -> dict[str, SourceStatus]:
611
+ """List all attached sources and their status.
612
+
613
+ Returns:
614
+ Dict mapping channel_id to current SourceStatus.
615
+ """
616
+ return {cid: source.status for cid, source in self._sources.items()}
617
+
618
+ # -- Hook decorators --
619
+
620
+ def hook(
621
+ self,
622
+ trigger: HookTrigger,
623
+ execution: HookExecution = HookExecution.SYNC,
624
+ priority: int = 0,
625
+ name: str = "",
626
+ timeout: float = 30.0,
627
+ channel_types: set[ChannelType] | None = None,
628
+ channel_ids: set[str] | None = None,
629
+ directions: set[ChannelDirection] | None = None,
630
+ ) -> Callable[..., Any]:
631
+ """Decorator to register a global hook.
632
+
633
+ Args:
634
+ trigger: When the hook fires (BEFORE_BROADCAST, AFTER_BROADCAST, etc.)
635
+ execution: SYNC (can block/modify) or ASYNC (fire-and-forget)
636
+ priority: Lower numbers run first (default: 0)
637
+ name: Optional name for logging and removal
638
+ timeout: Max execution time in seconds (default: 30.0)
639
+ channel_types: Only run for events from these channel types (None = all)
640
+ channel_ids: Only run for events from these channel IDs (None = all)
641
+ directions: Only run for events with these directions (None = all)
642
+ """
643
+
644
+ def decorator(fn: SyncHookFn | AsyncHookFn) -> SyncHookFn | AsyncHookFn:
645
+ self._hook_engine.register(
646
+ HookRegistration(
647
+ trigger=trigger,
648
+ execution=execution,
649
+ fn=fn,
650
+ priority=priority,
651
+ name=name or fn.__name__,
652
+ timeout=timeout,
653
+ channel_types=channel_types,
654
+ channel_ids=channel_ids,
655
+ directions=directions,
656
+ )
657
+ )
658
+ return fn
659
+
660
+ return decorator
661
+
662
+ def on(self, event_type: str) -> Callable[..., Any]:
663
+ """Decorator to register a framework event handler filtered by type."""
664
+
665
+ def decorator(fn: FrameworkEventHandler) -> FrameworkEventHandler:
666
+ self._event_handlers.append((event_type, fn))
667
+ return fn
668
+
669
+ return decorator
670
+
671
+ def identity_hook(
672
+ self,
673
+ trigger: HookTrigger,
674
+ channel_types: set[ChannelType] | None = None,
675
+ channel_ids: set[str] | None = None,
676
+ directions: set[ChannelDirection] | None = None,
677
+ ) -> Callable[..., Any]:
678
+ """Decorator to register an identity-resolution hook.
679
+
680
+ The decorated function receives ``(event, context, id_result)`` and
681
+ returns an ``IdentityHookResult`` or ``None``.
682
+
683
+ Args:
684
+ trigger: When the hook fires (ON_IDENTITY_AMBIGUOUS, ON_IDENTITY_UNKNOWN).
685
+ channel_types: Only run for events from these channel types (None = all).
686
+ channel_ids: Only run for events from these channel IDs (None = all).
687
+ directions: Only run for events with these directions (None = all).
688
+ """
689
+
690
+ def decorator(fn: IdentityHookFn) -> IdentityHookFn:
691
+ registration = IdentityHookRegistration(
692
+ trigger=trigger,
693
+ fn=fn,
694
+ channel_types=channel_types,
695
+ channel_ids=channel_ids,
696
+ directions=directions,
697
+ )
698
+ self._identity_hooks.setdefault(trigger, []).append(registration)
699
+ return fn
700
+
701
+ return decorator
702
+
703
+ def on_delivery_status(self, fn: DeliveryStatusHandler) -> DeliveryStatusHandler:
704
+ """Decorator to register a delivery status handler.
705
+
706
+ The decorated function is called when ``process_delivery_status()`` is
707
+ invoked with a ``DeliveryStatus`` from a provider webhook.
708
+
709
+ Example:
710
+ @kit.on_delivery_status
711
+ async def track_delivery(status: DeliveryStatus):
712
+ if status.status == "delivered":
713
+ logger.info("Message %s delivered to %s", status.message_id, status.recipient)
714
+ elif status.status == "failed":
715
+ logger.error("Message %s failed: %s", status.message_id, status.error_message)
716
+ """
717
+ self._delivery_status_handlers.append(fn)
718
+ return fn
719
+
720
+ async def process_webhook(
721
+ self,
722
+ meta: WebhookMeta,
723
+ channel_id: str,
724
+ ) -> None:
725
+ """Process any SMS provider webhook automatically.
726
+
727
+ This is the simplest integration method. It handles:
728
+ - Inbound messages → process_inbound() with all hooks
729
+ - Delivery status → process_delivery_status() with ON_DELIVERY_STATUS hooks
730
+ - Unknown webhooks → silently ignored (acknowledged)
731
+
732
+ Args:
733
+ meta: WebhookMeta from extract_sms_meta().
734
+ channel_id: The channel ID for inbound messages.
735
+
736
+ Example:
737
+ @app.post("/webhooks/sms/{provider}/inbound")
738
+ async def sms_webhook(provider: str, payload: dict):
739
+ meta = extract_sms_meta(provider, payload)
740
+ await kit.process_webhook(meta, channel_id=f"sms-{provider}")
741
+ return {"ok": True}
742
+ """
743
+ if meta.is_inbound:
744
+ inbound = meta.to_inbound(channel_id)
745
+ await self.process_inbound(inbound)
746
+ elif meta.is_status:
747
+ status = meta.to_status()
748
+ await self.process_delivery_status(status)
749
+ # else: unknown webhook type, silently acknowledge
750
+
751
+ async def process_delivery_status(self, status: DeliveryStatus) -> None:
752
+ """Process a delivery status through registered ON_DELIVERY_STATUS handlers.
753
+
754
+ Args:
755
+ status: The DeliveryStatus from meta.to_status().
756
+ """
757
+ logger = logging.getLogger("roomkit.framework")
758
+ for handler in self._delivery_status_handlers:
759
+ try:
760
+ result = handler(status)
761
+ if inspect.iscoroutine(result):
762
+ await result
763
+ except Exception:
764
+ logger.exception(
765
+ "Delivery status handler %s failed for message %s",
766
+ getattr(handler, "__name__", "unknown"),
767
+ status.message_id,
768
+ )
769
+
770
+ def add_room_hook(
771
+ self,
772
+ room_id: str,
773
+ trigger: HookTrigger,
774
+ execution: HookExecution,
775
+ fn: SyncHookFn | AsyncHookFn,
776
+ priority: int = 0,
777
+ name: str = "",
778
+ ) -> None:
779
+ """Add a hook for a specific room."""
780
+ self._hook_engine.add_room_hook(
781
+ room_id,
782
+ HookRegistration(
783
+ trigger=trigger,
784
+ execution=execution,
785
+ fn=fn,
786
+ priority=priority,
787
+ name=name,
788
+ ),
789
+ )
790
+
791
+ def remove_room_hook(self, room_id: str, name: str) -> bool:
792
+ """Remove a room hook by name."""
793
+ return self._hook_engine.remove_room_hook(room_id, name)