roomkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- roomkit/AGENTS.md +362 -0
- roomkit/__init__.py +372 -0
- roomkit/_version.py +1 -0
- roomkit/ai_docs.py +93 -0
- roomkit/channels/__init__.py +194 -0
- roomkit/channels/ai.py +238 -0
- roomkit/channels/base.py +66 -0
- roomkit/channels/transport.py +115 -0
- roomkit/channels/websocket.py +85 -0
- roomkit/core/__init__.py +0 -0
- roomkit/core/_channel_ops.py +252 -0
- roomkit/core/_helpers.py +296 -0
- roomkit/core/_inbound.py +435 -0
- roomkit/core/_room_lifecycle.py +275 -0
- roomkit/core/circuit_breaker.py +84 -0
- roomkit/core/event_router.py +401 -0
- roomkit/core/framework.py +793 -0
- roomkit/core/hooks.py +232 -0
- roomkit/core/inbound_router.py +57 -0
- roomkit/core/locks.py +66 -0
- roomkit/core/rate_limiter.py +67 -0
- roomkit/core/retry.py +49 -0
- roomkit/core/router.py +24 -0
- roomkit/core/transcoder.py +85 -0
- roomkit/identity/__init__.py +0 -0
- roomkit/identity/base.py +27 -0
- roomkit/identity/mock.py +49 -0
- roomkit/llms.txt +52 -0
- roomkit/models/__init__.py +104 -0
- roomkit/models/channel.py +99 -0
- roomkit/models/context.py +35 -0
- roomkit/models/delivery.py +76 -0
- roomkit/models/enums.py +170 -0
- roomkit/models/event.py +203 -0
- roomkit/models/framework_event.py +19 -0
- roomkit/models/hook.py +68 -0
- roomkit/models/identity.py +81 -0
- roomkit/models/participant.py +34 -0
- roomkit/models/room.py +33 -0
- roomkit/models/task.py +36 -0
- roomkit/providers/__init__.py +0 -0
- roomkit/providers/ai/__init__.py +0 -0
- roomkit/providers/ai/base.py +140 -0
- roomkit/providers/ai/mock.py +33 -0
- roomkit/providers/anthropic/__init__.py +6 -0
- roomkit/providers/anthropic/ai.py +145 -0
- roomkit/providers/anthropic/config.py +14 -0
- roomkit/providers/elasticemail/__init__.py +6 -0
- roomkit/providers/elasticemail/config.py +16 -0
- roomkit/providers/elasticemail/email.py +97 -0
- roomkit/providers/email/__init__.py +0 -0
- roomkit/providers/email/base.py +46 -0
- roomkit/providers/email/mock.py +34 -0
- roomkit/providers/gemini/__init__.py +6 -0
- roomkit/providers/gemini/ai.py +153 -0
- roomkit/providers/gemini/config.py +14 -0
- roomkit/providers/http/__init__.py +15 -0
- roomkit/providers/http/base.py +33 -0
- roomkit/providers/http/config.py +14 -0
- roomkit/providers/http/mock.py +21 -0
- roomkit/providers/http/provider.py +105 -0
- roomkit/providers/http/webhook.py +33 -0
- roomkit/providers/messenger/__init__.py +15 -0
- roomkit/providers/messenger/base.py +33 -0
- roomkit/providers/messenger/config.py +17 -0
- roomkit/providers/messenger/facebook.py +95 -0
- roomkit/providers/messenger/mock.py +21 -0
- roomkit/providers/messenger/webhook.py +42 -0
- roomkit/providers/openai/__init__.py +6 -0
- roomkit/providers/openai/ai.py +155 -0
- roomkit/providers/openai/config.py +24 -0
- roomkit/providers/pydantic_ai/__init__.py +5 -0
- roomkit/providers/pydantic_ai/config.py +14 -0
- roomkit/providers/rcs/__init__.py +9 -0
- roomkit/providers/rcs/base.py +95 -0
- roomkit/providers/rcs/mock.py +78 -0
- roomkit/providers/sendgrid/__init__.py +5 -0
- roomkit/providers/sendgrid/config.py +13 -0
- roomkit/providers/sinch/__init__.py +6 -0
- roomkit/providers/sinch/config.py +22 -0
- roomkit/providers/sinch/sms.py +192 -0
- roomkit/providers/sms/__init__.py +15 -0
- roomkit/providers/sms/base.py +67 -0
- roomkit/providers/sms/meta.py +401 -0
- roomkit/providers/sms/mock.py +24 -0
- roomkit/providers/sms/phone.py +77 -0
- roomkit/providers/telnyx/__init__.py +21 -0
- roomkit/providers/telnyx/config.py +14 -0
- roomkit/providers/telnyx/rcs.py +352 -0
- roomkit/providers/telnyx/sms.py +231 -0
- roomkit/providers/twilio/__init__.py +18 -0
- roomkit/providers/twilio/config.py +19 -0
- roomkit/providers/twilio/rcs.py +183 -0
- roomkit/providers/twilio/sms.py +200 -0
- roomkit/providers/voicemeup/__init__.py +15 -0
- roomkit/providers/voicemeup/config.py +21 -0
- roomkit/providers/voicemeup/sms.py +374 -0
- roomkit/providers/whatsapp/__init__.py +0 -0
- roomkit/providers/whatsapp/base.py +44 -0
- roomkit/providers/whatsapp/mock.py +21 -0
- roomkit/py.typed +0 -0
- roomkit/realtime/__init__.py +17 -0
- roomkit/realtime/base.py +111 -0
- roomkit/realtime/memory.py +158 -0
- roomkit/sources/__init__.py +35 -0
- roomkit/sources/base.py +207 -0
- roomkit/sources/websocket.py +260 -0
- roomkit/store/__init__.py +0 -0
- roomkit/store/base.py +230 -0
- roomkit/store/memory.py +293 -0
- roomkit-0.1.0.dist-info/METADATA +567 -0
- roomkit-0.1.0.dist-info/RECORD +114 -0
- roomkit-0.1.0.dist-info/WHEEL +4 -0
- roomkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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)
|