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,275 @@
1
+ """RoomLifecycleMixin — room CRUD and participant management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import UTC, datetime
7
+ from typing import TYPE_CHECKING, Any
8
+ from uuid import uuid4
9
+
10
+ from roomkit.core._helpers import HelpersMixin
11
+ from roomkit.models.enums import (
12
+ EventType,
13
+ HookTrigger,
14
+ IdentificationStatus,
15
+ RoomStatus,
16
+ )
17
+ from roomkit.models.participant import Participant
18
+ from roomkit.models.room import Room
19
+
20
+ if TYPE_CHECKING:
21
+ from roomkit.core.locks import RoomLockManager
22
+ from roomkit.store.base import ConversationStore
23
+
24
+ logger = logging.getLogger("roomkit.framework")
25
+
26
+
27
+ class RoomLifecycleMixin(HelpersMixin):
28
+ """Room lifecycle operations: create, close, timers, participants."""
29
+
30
+ _store: ConversationStore
31
+ _lock_manager: RoomLockManager
32
+
33
+ async def create_room(
34
+ self, room_id: str | None = None, metadata: dict[str, Any] | None = None
35
+ ) -> Room:
36
+ """Create a new room."""
37
+ room = Room(
38
+ id=room_id or uuid4().hex,
39
+ metadata=metadata or {},
40
+ )
41
+ result = await self._store.create_room(room)
42
+ await self._fire_lifecycle_hook(
43
+ room.id,
44
+ HookTrigger.ON_ROOM_CREATED,
45
+ EventType.SYSTEM,
46
+ code="room_created",
47
+ message=f"Room {room.id} created",
48
+ data={"room_id": room.id},
49
+ )
50
+ await self._emit_framework_event(
51
+ "room_created", room_id=room.id, data={"room_id": room.id}
52
+ )
53
+ return result
54
+
55
+ async def get_room(self, room_id: str) -> Room:
56
+ """Get a room by ID. Raises RoomNotFoundError if missing."""
57
+ room = await self._store.get_room(room_id)
58
+ if room is None:
59
+ from roomkit.core.framework import RoomNotFoundError
60
+
61
+ raise RoomNotFoundError(f"Room {room_id} not found")
62
+ return room
63
+
64
+ async def close_room(self, room_id: str) -> Room:
65
+ """Close a room."""
66
+ async with self._lock_manager.locked(room_id):
67
+ room = await self.get_room(room_id)
68
+ room = room.model_copy(
69
+ update={"status": RoomStatus.CLOSED, "closed_at": datetime.now(UTC)}
70
+ )
71
+ result = await self._store.update_room(room)
72
+ await self._fire_lifecycle_hook(
73
+ room_id,
74
+ HookTrigger.ON_ROOM_CLOSED,
75
+ EventType.SYSTEM,
76
+ code="room_closed",
77
+ message=f"Room {room_id} closed",
78
+ data={"room_id": room_id},
79
+ )
80
+ await self._emit_framework_event(
81
+ "room_closed", room_id=room_id, data={"room_id": room_id}
82
+ )
83
+ return result
84
+
85
+ async def check_room_timers(self, room_id: str) -> Room:
86
+ """Check and apply timer-based transitions for a single room.
87
+
88
+ Returns the room (possibly transitioned to PAUSED or CLOSED).
89
+ """
90
+ async with self._lock_manager.locked(room_id):
91
+ room = await self.get_room(room_id)
92
+
93
+ if room.status in (RoomStatus.CLOSED, RoomStatus.ARCHIVED):
94
+ return room
95
+
96
+ timers = room.timers
97
+ if timers.last_activity_at is None:
98
+ return room
99
+
100
+ elapsed = (datetime.now(UTC) - timers.last_activity_at).total_seconds()
101
+
102
+ # Check closed threshold first (supersedes pause)
103
+ if timers.closed_after_seconds is not None and elapsed > timers.closed_after_seconds:
104
+ if room.status != RoomStatus.CLOSED:
105
+ room = room.model_copy(
106
+ update={"status": RoomStatus.CLOSED, "closed_at": datetime.now(UTC)}
107
+ )
108
+ await self._store.update_room(room)
109
+ await self._emit_system_event(
110
+ room_id,
111
+ EventType.SYSTEM,
112
+ code="room_closed_by_timer",
113
+ message=f"Room {room_id} closed after {elapsed:.0f}s inactivity",
114
+ data={
115
+ "elapsed_seconds": elapsed,
116
+ "threshold": timers.closed_after_seconds,
117
+ },
118
+ )
119
+ await self._fire_lifecycle_hook(
120
+ room_id,
121
+ HookTrigger.ON_ROOM_CLOSED,
122
+ EventType.SYSTEM,
123
+ code="room_closed_by_timer",
124
+ message=f"Room {room_id} closed by timer",
125
+ data={"elapsed_seconds": elapsed},
126
+ )
127
+ await self._emit_framework_event(
128
+ "room_closed", room_id=room_id, data={"reason": "timer"}
129
+ )
130
+ return room
131
+
132
+ # Check pause threshold (only for ACTIVE rooms)
133
+ if (
134
+ room.status == RoomStatus.ACTIVE
135
+ and timers.inactive_after_seconds is not None
136
+ and elapsed > timers.inactive_after_seconds
137
+ ):
138
+ room = room.model_copy(
139
+ update={"status": RoomStatus.PAUSED, "updated_at": datetime.now(UTC)}
140
+ )
141
+ await self._store.update_room(room)
142
+ await self._emit_system_event(
143
+ room_id,
144
+ EventType.SYSTEM,
145
+ code="room_paused_by_timer",
146
+ message=f"Room {room_id} paused after {elapsed:.0f}s inactivity",
147
+ data={"elapsed_seconds": elapsed, "threshold": timers.inactive_after_seconds},
148
+ )
149
+ await self._fire_lifecycle_hook(
150
+ room_id,
151
+ HookTrigger.ON_ROOM_PAUSED,
152
+ EventType.SYSTEM,
153
+ code="room_paused_by_timer",
154
+ message=f"Room {room_id} paused by timer",
155
+ data={"elapsed_seconds": elapsed},
156
+ )
157
+ await self._emit_framework_event(
158
+ "room_paused", room_id=room_id, data={"reason": "timer"}
159
+ )
160
+
161
+ return room
162
+
163
+ async def check_all_timers(self) -> list[Room]:
164
+ """Check timers on all active/paused rooms. Returns rooms that transitioned."""
165
+ transitioned: list[Room] = []
166
+ for status in (RoomStatus.ACTIVE, RoomStatus.PAUSED):
167
+ rooms = await self._store.find_rooms(status=status.value)
168
+ for room in rooms:
169
+ old_status = room.status
170
+ updated = await self.check_room_timers(room.id)
171
+ if updated.status != old_status:
172
+ transitioned.append(updated)
173
+ return transitioned
174
+
175
+ async def update_room_metadata(self, room_id: str, metadata: dict[str, Any]) -> Room:
176
+ """Update room metadata."""
177
+ async with self._lock_manager.locked(room_id):
178
+ room = await self.get_room(room_id)
179
+ room = room.model_copy(
180
+ update={"metadata": {**room.metadata, **metadata}, "updated_at": datetime.now(UTC)}
181
+ )
182
+ return await self._store.update_room(room)
183
+
184
+ async def ensure_participant(
185
+ self,
186
+ room_id: str,
187
+ channel_id: str,
188
+ participant_id: str,
189
+ display_name: str | None = None,
190
+ ) -> Participant:
191
+ """Get an existing participant or create one."""
192
+ existing = await self._store.get_participant(room_id, participant_id)
193
+ if existing:
194
+ return existing
195
+ participant = Participant(
196
+ id=participant_id,
197
+ room_id=room_id,
198
+ channel_id=channel_id,
199
+ display_name=display_name,
200
+ )
201
+ return await self._store.add_participant(participant)
202
+
203
+ async def resolve_participant(
204
+ self,
205
+ room_id: str,
206
+ participant_id: str,
207
+ identity_id: str,
208
+ resolved_by: str = "manual",
209
+ ) -> Participant:
210
+ """Resolve a pending participant to a known identity (RFC 7.4).
211
+
212
+ Called by an advisor or automated process when a pending/ambiguous
213
+ participant has been identified.
214
+ """
215
+ async with self._lock_manager.locked(room_id):
216
+ participant = await self._store.get_participant(room_id, participant_id)
217
+ if participant is None:
218
+ from roomkit.core.framework import ParticipantNotFoundError
219
+
220
+ raise ParticipantNotFoundError(
221
+ f"Participant {participant_id} not found in room {room_id}"
222
+ )
223
+
224
+ identity = await self._store.get_identity(identity_id)
225
+ if identity is None:
226
+ from roomkit.core.framework import IdentityNotFoundError
227
+
228
+ raise IdentityNotFoundError(f"Identity {identity_id} not found")
229
+
230
+ # Update participant fields
231
+ participant = participant.model_copy(
232
+ update={
233
+ "identification": IdentificationStatus.IDENTIFIED,
234
+ "identity_id": identity_id,
235
+ "resolved_at": datetime.now(UTC),
236
+ "resolved_by": resolved_by,
237
+ "candidates": None,
238
+ "display_name": identity.display_name or participant.display_name,
239
+ }
240
+ )
241
+ await self._store.update_participant(participant)
242
+
243
+ # Update binding if present
244
+ binding = await self._store.get_binding(room_id, participant.channel_id)
245
+ if binding:
246
+ binding = binding.model_copy(update={"participant_id": identity_id})
247
+ await self._store.update_binding(binding)
248
+
249
+ # Emit system event
250
+ await self._emit_system_event(
251
+ room_id,
252
+ EventType.PARTICIPANT_IDENTIFIED,
253
+ code="participant_identified",
254
+ message=f"Participant {participant_id} identified as {identity.display_name}",
255
+ data={
256
+ "participant_id": participant_id,
257
+ "identity_id": identity_id,
258
+ "resolved_by": resolved_by,
259
+ },
260
+ )
261
+
262
+ # Fire lifecycle hook
263
+ await self._fire_lifecycle_hook(
264
+ room_id,
265
+ HookTrigger.ON_PARTICIPANT_IDENTIFIED,
266
+ EventType.PARTICIPANT_IDENTIFIED,
267
+ code="participant_identified",
268
+ message="Participant identified",
269
+ data={
270
+ "participant_id": participant_id,
271
+ "identity_id": identity_id,
272
+ },
273
+ )
274
+
275
+ return participant
@@ -0,0 +1,84 @@
1
+ """Circuit breaker for provider fault isolation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+
8
+ class CircuitBreaker:
9
+ """Simple circuit breaker (closed → open → half-open → closed).
10
+
11
+ * **Closed** — requests flow normally.
12
+ * **Open** — all requests fail immediately (after *failure_threshold* consecutive failures).
13
+ * **Half-open** — after *recovery_timeout* seconds, allow one probe request.
14
+
15
+ **Concurrency note:** This implementation relies on the CPython single-threaded
16
+ asyncio model. State mutations in ``allow_request``, ``record_success``, and
17
+ ``record_failure`` contain no ``await`` between the check and the set, making
18
+ them atomic within a single event-loop iteration. No additional locking is
19
+ required as long as all callers run in the same event loop.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ failure_threshold: int = 5,
25
+ recovery_timeout: float = 60.0,
26
+ ) -> None:
27
+ self._failure_threshold = failure_threshold
28
+ self._recovery_timeout = recovery_timeout
29
+ self._failure_count: int = 0
30
+ self._opened_at: float | None = None
31
+ self._half_open_probe_sent: bool = False
32
+
33
+ # -- State queries --
34
+
35
+ @property
36
+ def is_open(self) -> bool:
37
+ """True when the breaker is fully open (not half-open)."""
38
+ if self._opened_at is None:
39
+ return False
40
+ elapsed = time.monotonic() - self._opened_at
41
+ return elapsed < self._recovery_timeout
42
+
43
+ @property
44
+ def is_half_open(self) -> bool:
45
+ """True when recovery timeout has elapsed and a probe is allowed."""
46
+ if self._opened_at is None:
47
+ return False
48
+ return (time.monotonic() - self._opened_at) >= self._recovery_timeout
49
+
50
+ @property
51
+ def is_closed(self) -> bool:
52
+ return self._opened_at is None
53
+
54
+ def allow_request(self) -> bool:
55
+ """Return True if a request should be attempted."""
56
+ if self.is_closed:
57
+ return True
58
+ if self.is_half_open and not self._half_open_probe_sent:
59
+ self._half_open_probe_sent = True
60
+ return True
61
+ return False
62
+
63
+ # -- Recording --
64
+
65
+ def record_success(self) -> None:
66
+ """Record a successful call — resets the breaker to closed."""
67
+ self._failure_count = 0
68
+ self._opened_at = None
69
+ self._half_open_probe_sent = False
70
+
71
+ def record_failure(self) -> None:
72
+ """Record a failed call — may trip the breaker open."""
73
+ self._failure_count += 1
74
+ if self._failure_count >= self._failure_threshold:
75
+ self._opened_at = time.monotonic()
76
+ self._half_open_probe_sent = False
77
+
78
+ # -- Reset --
79
+
80
+ def reset(self) -> None:
81
+ """Manually close the breaker."""
82
+ self._failure_count = 0
83
+ self._opened_at = None
84
+ self._half_open_probe_sent = False