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,374 @@
1
+ """VoiceMeUp SMS provider — sends SMS via the VoiceMeUp REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from collections.abc import Awaitable, Callable
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from roomkit.models.delivery import InboundMessage, ProviderResult
13
+ from roomkit.models.event import RoomEvent
14
+ from roomkit.providers.sms.base import SMSProvider
15
+ from roomkit.providers.sms.meta import (
16
+ build_inbound_content,
17
+ extract_media_urls,
18
+ extract_text_body,
19
+ )
20
+ from roomkit.providers.voicemeup.config import VoiceMeUpConfig
21
+
22
+ if TYPE_CHECKING:
23
+ import httpx
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _MAX_SEGMENT_LENGTH = 1000
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # MMS Aggregation — automatic handling of VoiceMeUp's split MMS webhooks
31
+ # ---------------------------------------------------------------------------
32
+ #
33
+ # VoiceMeUp sends MMS as two separate webhooks:
34
+ # 1. First: text message + .mms.html metadata wrapper (not real media)
35
+ # 2. Second: actual media attachment (no text)
36
+ #
37
+ # This module automatically buffers the first webhook and merges it with
38
+ # the second to produce a single InboundMessage with both text and media.
39
+ #
40
+ # Usage:
41
+ # message = parse_voicemeup_webhook(payload, channel_id="sms")
42
+ # if message:
43
+ # await kit.process_inbound(message)
44
+ # # If None, webhook was buffered (waiting for second part)
45
+ #
46
+ # Configure timeout callback (optional):
47
+ # configure_voicemeup_mms(
48
+ # timeout_seconds=5.0,
49
+ # on_timeout=my_handler, # Called with text-only message if image never arrives
50
+ # )
51
+ # ---------------------------------------------------------------------------
52
+
53
+
54
+ @dataclass
55
+ class _PendingMMS:
56
+ """Buffered first part of a split MMS webhook."""
57
+
58
+ payload: dict[str, Any]
59
+ text: str
60
+ timestamp: float
61
+ channel_id: str
62
+
63
+
64
+ # Module-level state for MMS aggregation
65
+ _mms_buffer: dict[str, _PendingMMS] = {}
66
+ _mms_timeout_seconds: float = 5.0
67
+ _mms_on_timeout: Callable[[InboundMessage], Awaitable[None] | None] | None = None
68
+
69
+
70
+ def configure_voicemeup_mms(
71
+ *,
72
+ timeout_seconds: float = 5.0,
73
+ on_timeout: Callable[[InboundMessage], Awaitable[None] | None] | None = None,
74
+ ) -> None:
75
+ """Configure VoiceMeUp MMS aggregation behavior.
76
+
77
+ Args:
78
+ timeout_seconds: How long to wait for the second MMS part (default: 5.0)
79
+ on_timeout: Callback invoked with text-only message if image never arrives.
80
+ If not set, orphaned text messages are logged and discarded.
81
+
82
+ Example:
83
+ async def handle_orphaned_mms(message: InboundMessage) -> None:
84
+ await kit.process_inbound(message)
85
+
86
+ configure_voicemeup_mms(timeout_seconds=5.0, on_timeout=handle_orphaned_mms)
87
+ """
88
+ global _mms_timeout_seconds, _mms_on_timeout
89
+ _mms_timeout_seconds = timeout_seconds
90
+ _mms_on_timeout = on_timeout
91
+
92
+
93
+ def _is_mms_metadata_wrapper(url: str | None, mime_type: str | None) -> bool:
94
+ """Check if attachment is a VoiceMeUp MMS metadata wrapper (not real media)."""
95
+ if not url:
96
+ return False
97
+ if url.endswith(".mms.html"):
98
+ return True
99
+ return mime_type == "text/html" and ".mms." in url
100
+
101
+
102
+ def _make_correlation_key(payload: dict[str, Any]) -> str:
103
+ """Create a key to correlate split MMS webhooks."""
104
+ return (
105
+ f"{payload.get('source_number', '')}:"
106
+ f"{payload.get('destination_number', '')}:"
107
+ f"{payload.get('datetime_transmission', '')}"
108
+ )
109
+
110
+
111
+ async def _handle_mms_timeout(key: str) -> None:
112
+ """Emit buffered message as text-only if timeout expires."""
113
+ await asyncio.sleep(_mms_timeout_seconds)
114
+
115
+ if key not in _mms_buffer:
116
+ return # Already merged, nothing to do
117
+
118
+ pending = _mms_buffer.pop(key)
119
+
120
+ # Create text-only message (no media since .mms.html is useless)
121
+ payload_copy = dict(pending.payload)
122
+ payload_copy.pop("attachment", None)
123
+ payload_copy.pop("attachment_url", None)
124
+ payload_copy.pop("attachment_mime_type", None)
125
+ payload_copy.pop("attachment_type", None)
126
+
127
+ message = _build_inbound_message(payload_copy, pending.channel_id)
128
+
129
+ if _mms_on_timeout:
130
+ logger.debug("VoiceMeUp MMS timeout: invoking on_timeout callback for %s", key)
131
+ result = _mms_on_timeout(message)
132
+ if asyncio.iscoroutine(result):
133
+ await result
134
+ else:
135
+ logger.warning(
136
+ "VoiceMeUp MMS timeout: discarding orphaned text message from %s "
137
+ "(configure on_timeout to handle this)",
138
+ pending.payload.get("source_number"),
139
+ )
140
+
141
+
142
+ def _build_inbound_message(payload: dict[str, Any], channel_id: str) -> InboundMessage:
143
+ """Build an InboundMessage from a VoiceMeUp webhook payload."""
144
+ body = payload.get("message", "")
145
+
146
+ media: list[dict[str, str | None]] = []
147
+ attachment_url = payload.get("attachment") or payload.get("attachment_url")
148
+ if attachment_url:
149
+ media.append(
150
+ {
151
+ "url": attachment_url,
152
+ "mime_type": payload.get("attachment_mime_type") or payload.get("attachment_type"),
153
+ }
154
+ )
155
+
156
+ return InboundMessage(
157
+ channel_id=channel_id,
158
+ sender_id=payload["source_number"],
159
+ content=build_inbound_content(body, media),
160
+ external_id=payload.get("sms_hash"),
161
+ idempotency_key=payload.get("sms_hash"),
162
+ metadata={
163
+ "destination_number": payload.get("destination_number", ""),
164
+ "direction": payload.get("direction", "inbound"),
165
+ "datetime_transmission": payload.get("datetime_transmission", ""),
166
+ "has_attachment": bool(media),
167
+ },
168
+ )
169
+
170
+
171
+ def parse_voicemeup_webhook(
172
+ payload: dict[str, Any],
173
+ channel_id: str,
174
+ ) -> InboundMessage | None:
175
+ """Parse a VoiceMeUp webhook and return an InboundMessage.
176
+
177
+ Automatically handles MMS aggregation: VoiceMeUp sends MMS as two separate
178
+ webhooks (text + metadata first, image second). This function buffers the
179
+ first part and merges it with the second.
180
+
181
+ Args:
182
+ payload: The webhook POST body from VoiceMeUp
183
+ channel_id: The channel ID to associate with this message
184
+
185
+ Returns:
186
+ InboundMessage if ready to process (SMS or merged MMS)
187
+ None if buffered (waiting for second MMS part)
188
+
189
+ Example:
190
+ @app.post("/webhooks/sms/voicemeup")
191
+ async def webhook(payload: dict):
192
+ message = parse_voicemeup_webhook(payload, channel_id="sms")
193
+ if message:
194
+ await kit.process_inbound(message)
195
+ return {"ok": True}
196
+ """
197
+ attachment_url = payload.get("attachment") or payload.get("attachment_url")
198
+ attachment_mime = payload.get("attachment_mime_type") or payload.get("attachment_type")
199
+
200
+ # Check if this is a metadata wrapper (first part of split MMS)
201
+ if _is_mms_metadata_wrapper(attachment_url, attachment_mime):
202
+ key = _make_correlation_key(payload)
203
+ text = payload.get("message", "")
204
+
205
+ _mms_buffer[key] = _PendingMMS(
206
+ payload=payload,
207
+ text=text,
208
+ timestamp=time.time(),
209
+ channel_id=channel_id,
210
+ )
211
+
212
+ # Schedule timeout (only if event loop is running)
213
+ try:
214
+ loop = asyncio.get_running_loop()
215
+ loop.create_task(_handle_mms_timeout(key))
216
+ except RuntimeError:
217
+ # No event loop running — timeout won't fire, but in real usage
218
+ # (FastAPI/Starlette) there always will be one
219
+ pass
220
+
221
+ logger.debug("VoiceMeUp MMS: buffered first part for %s", key)
222
+ return None
223
+
224
+ # Check if we have a buffered first part to merge with
225
+ key = _make_correlation_key(payload)
226
+ if key in _mms_buffer:
227
+ pending = _mms_buffer.pop(key)
228
+
229
+ # Build merged payload: text from first, media from second
230
+ merged_payload = dict(payload)
231
+ merged_payload["message"] = pending.text
232
+
233
+ # Combine sms_hash for traceability
234
+ first_hash = pending.payload.get("sms_hash", "")
235
+ second_hash = payload.get("sms_hash", "")
236
+ merged_payload["sms_hash"] = f"{first_hash}+{second_hash}"
237
+
238
+ logger.debug("VoiceMeUp MMS: merged text + media for %s", key)
239
+ return _build_inbound_message(merged_payload, channel_id)
240
+
241
+ # Regular SMS or standalone MMS — return directly
242
+ return _build_inbound_message(payload, channel_id)
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # SMS Provider
247
+ # ---------------------------------------------------------------------------
248
+
249
+
250
+ def _strip_plus(number: str) -> str:
251
+ """Strip leading '+' from an E.164 phone number."""
252
+ return number.lstrip("+")
253
+
254
+
255
+ class VoiceMeUpSMSProvider(SMSProvider):
256
+ """SMS provider using the VoiceMeUp REST API."""
257
+
258
+ def __init__(self, config: VoiceMeUpConfig) -> None:
259
+ try:
260
+ import httpx as _httpx
261
+ except ImportError as exc:
262
+ raise ImportError(
263
+ "httpx is required for VoiceMeUpSMSProvider. "
264
+ "Install it with: pip install roomkit[httpx]"
265
+ ) from exc
266
+ self._config = config
267
+ self._httpx = _httpx
268
+ self._client: httpx.AsyncClient = _httpx.AsyncClient(timeout=config.timeout)
269
+
270
+ @property
271
+ def from_number(self) -> str:
272
+ return self._config.from_number
273
+
274
+ async def send(self, event: RoomEvent, to: str, from_: str | None = None) -> ProviderResult:
275
+ body = extract_text_body(event.content)
276
+ media_urls = extract_media_urls(event.content)
277
+
278
+ if not body and not media_urls:
279
+ return ProviderResult(success=False, error="empty_message")
280
+
281
+ from_number = _strip_plus(from_ or self._config.from_number)
282
+ to_number = _strip_plus(to)
283
+
284
+ # MMS: VoiceMeUp supports one attachment per message
285
+ if media_urls:
286
+ return await self._send_message(
287
+ body or "", to_number, from_number, attachment=media_urls[0]
288
+ )
289
+
290
+ # SMS: split long messages into segments
291
+ segments = self._split_message(body)
292
+ last_result: ProviderResult | None = None
293
+
294
+ for segment in segments:
295
+ last_result = await self._send_message(segment, to_number, from_number)
296
+ if not last_result.success:
297
+ return last_result
298
+
299
+ assert last_result is not None
300
+ return last_result
301
+
302
+ async def _send_message(
303
+ self, message: str, to: str, from_: str, *, attachment: str | None = None
304
+ ) -> ProviderResult:
305
+ url = f"{self._config.base_url}queue_sms"
306
+ auth_params: dict[str, str] = {
307
+ "username": self._config.username,
308
+ "auth_token": self._config.auth_token.get_secret_value(),
309
+ }
310
+
311
+ form_data: dict[str, str] = {
312
+ "source_number": from_,
313
+ "destination_number": to,
314
+ }
315
+
316
+ if message:
317
+ form_data["message"] = message
318
+
319
+ if attachment:
320
+ form_data["attachment"] = attachment
321
+
322
+ try:
323
+ resp = await self._client.post(url, params=auth_params, data=form_data)
324
+ resp.raise_for_status()
325
+ data = resp.json()
326
+ except self._httpx.TimeoutException:
327
+ return ProviderResult(success=False, error="timeout")
328
+ except self._httpx.HTTPStatusError as exc:
329
+ return ProviderResult(
330
+ success=False,
331
+ error=f"http_{exc.response.status_code}",
332
+ )
333
+ except self._httpx.HTTPError as exc:
334
+ return ProviderResult(success=False, error=str(exc))
335
+
336
+ return self._parse_response(data)
337
+
338
+ @staticmethod
339
+ def _parse_response(data: dict[str, Any]) -> ProviderResult:
340
+ details = data.get("response_details", {})
341
+ status = details.get("response_status", "")
342
+
343
+ if status == "error":
344
+ messages = details.get("response_messages", {}).get("message", [])
345
+ if isinstance(messages, dict):
346
+ messages = [messages]
347
+ code = messages[0].get("code", "unknown_error") if messages else "unknown_error"
348
+ description = messages[0].get("_content", code) if messages else code
349
+ return ProviderResult(success=False, error=code, metadata={"description": description})
350
+
351
+ messages = details.get("response_messages", {}).get("message", [])
352
+ if isinstance(messages, dict):
353
+ messages = [messages]
354
+
355
+ sms_id: str | None = None
356
+ for msg in messages:
357
+ if msg.get("code") == "queued_sms_hash":
358
+ sms_id = msg.get("_content")
359
+ break
360
+
361
+ return ProviderResult(success=True, provider_message_id=sms_id)
362
+
363
+ @staticmethod
364
+ def _split_message(text: str) -> list[str]:
365
+ if len(text) <= _MAX_SEGMENT_LENGTH:
366
+ return [text]
367
+ segments: list[str] = []
368
+ while text:
369
+ segments.append(text[:_MAX_SEGMENT_LENGTH])
370
+ text = text[_MAX_SEGMENT_LENGTH:]
371
+ return segments
372
+
373
+ async def close(self) -> None:
374
+ await self._client.aclose()
File without changes
@@ -0,0 +1,44 @@
1
+ """Abstract base class for WhatsApp providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+ from roomkit.models.delivery import InboundMessage, ProviderResult
9
+ from roomkit.models.event import RoomEvent
10
+
11
+
12
+ class WhatsAppProvider(ABC):
13
+ """WhatsApp delivery provider."""
14
+
15
+ @property
16
+ def name(self) -> str:
17
+ """Provider name (e.g. 'meta', 'twilio_wa')."""
18
+ return self.__class__.__name__
19
+
20
+ @abstractmethod
21
+ async def send(self, event: RoomEvent, to: str) -> ProviderResult:
22
+ """Send a WhatsApp message.
23
+
24
+ Args:
25
+ event: The room event containing the message content.
26
+ to: Recipient WhatsApp ID or phone number.
27
+
28
+ Returns:
29
+ Result with provider-specific delivery metadata.
30
+ """
31
+ ...
32
+
33
+ async def parse_webhook(self, payload: dict[str, Any]) -> InboundMessage:
34
+ """Parse an inbound webhook payload into an InboundMessage."""
35
+ raise NotImplementedError
36
+
37
+ async def send_template(
38
+ self, to: str, template_name: str, params: dict[str, Any] | None = None
39
+ ) -> ProviderResult:
40
+ """Send a template message."""
41
+ raise NotImplementedError
42
+
43
+ async def close(self) -> None: # noqa: B027
44
+ """Release resources. Override in subclasses that hold connections."""
@@ -0,0 +1,21 @@
1
+ """Mock WhatsApp provider for testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from uuid import uuid4
7
+
8
+ from roomkit.models.delivery import ProviderResult
9
+ from roomkit.models.event import RoomEvent
10
+ from roomkit.providers.whatsapp.base import WhatsAppProvider
11
+
12
+
13
+ class MockWhatsAppProvider(WhatsAppProvider):
14
+ """Records sent messages for verification in tests."""
15
+
16
+ def __init__(self) -> None:
17
+ self.sent: list[dict[str, Any]] = []
18
+
19
+ async def send(self, event: RoomEvent, to: str) -> ProviderResult:
20
+ self.sent.append({"event": event, "to": to})
21
+ return ProviderResult(success=True, provider_message_id=uuid4().hex)
roomkit/py.typed ADDED
File without changes
@@ -0,0 +1,17 @@
1
+ """Realtime backend for ephemeral events."""
2
+
3
+ from roomkit.realtime.base import (
4
+ EphemeralCallback,
5
+ EphemeralEvent,
6
+ EphemeralEventType,
7
+ RealtimeBackend,
8
+ )
9
+ from roomkit.realtime.memory import InMemoryRealtime
10
+
11
+ __all__ = [
12
+ "EphemeralCallback",
13
+ "EphemeralEvent",
14
+ "EphemeralEventType",
15
+ "InMemoryRealtime",
16
+ "RealtimeBackend",
17
+ ]
@@ -0,0 +1,111 @@
1
+ """Abstract base class and types for realtime backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Callable, Coroutine
7
+ from dataclasses import dataclass, field
8
+ from datetime import UTC, datetime
9
+ from enum import StrEnum
10
+ from typing import Any
11
+ from uuid import uuid4
12
+
13
+
14
+ class EphemeralEventType(StrEnum):
15
+ """Types of ephemeral events."""
16
+
17
+ TYPING_START = "typing_start"
18
+ TYPING_STOP = "typing_stop"
19
+ PRESENCE_ONLINE = "presence_online"
20
+ PRESENCE_AWAY = "presence_away"
21
+ PRESENCE_OFFLINE = "presence_offline"
22
+ READ_RECEIPT = "read_receipt"
23
+ CUSTOM = "custom"
24
+
25
+
26
+ @dataclass
27
+ class EphemeralEvent:
28
+ """An ephemeral event that doesn't require persistence."""
29
+
30
+ room_id: str
31
+ type: EphemeralEventType
32
+ user_id: str
33
+ id: str = field(default_factory=lambda: uuid4().hex)
34
+ channel_id: str | None = None
35
+ data: dict[str, Any] = field(default_factory=dict)
36
+ timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
37
+
38
+ def to_dict(self) -> dict[str, Any]:
39
+ """Convert to a JSON-serializable dictionary."""
40
+ return {
41
+ "id": self.id,
42
+ "room_id": self.room_id,
43
+ "type": self.type.value,
44
+ "user_id": self.user_id,
45
+ "channel_id": self.channel_id,
46
+ "data": self.data,
47
+ "timestamp": self.timestamp.isoformat(),
48
+ }
49
+
50
+ @classmethod
51
+ def from_dict(cls, data: dict[str, Any]) -> EphemeralEvent:
52
+ """Create an EphemeralEvent from a dictionary."""
53
+ return cls(
54
+ id=data["id"],
55
+ room_id=data["room_id"],
56
+ type=EphemeralEventType(data["type"]),
57
+ user_id=data["user_id"],
58
+ channel_id=data.get("channel_id"),
59
+ data=data.get("data", {}),
60
+ timestamp=datetime.fromisoformat(data["timestamp"]),
61
+ )
62
+
63
+
64
+ EphemeralCallback = Callable[[EphemeralEvent], Coroutine[Any, Any, None]]
65
+
66
+
67
+ class RealtimeBackend(ABC):
68
+ """Abstract base for realtime pub/sub backends.
69
+
70
+ Implement this to plug in any realtime backend (Redis pub/sub, NATS, etc.).
71
+ The library ships with ``InMemoryRealtime`` for single-process deployments.
72
+ """
73
+
74
+ @abstractmethod
75
+ async def publish(self, channel: str, event: EphemeralEvent) -> None:
76
+ """Publish an event to a channel."""
77
+ ...
78
+
79
+ @abstractmethod
80
+ async def subscribe(self, channel: str, callback: EphemeralCallback) -> str:
81
+ """Subscribe to a channel.
82
+
83
+ Returns:
84
+ A subscription ID that can be used to unsubscribe.
85
+ """
86
+ ...
87
+
88
+ @abstractmethod
89
+ async def unsubscribe(self, subscription_id: str) -> bool:
90
+ """Unsubscribe from a channel.
91
+
92
+ Returns:
93
+ True if the subscription existed and was removed.
94
+ """
95
+ ...
96
+
97
+ async def publish_to_room(self, room_id: str, event: EphemeralEvent) -> None:
98
+ """Convenience method to publish an event to a room channel."""
99
+ await self.publish(f"room:{room_id}", event)
100
+
101
+ async def subscribe_to_room(self, room_id: str, callback: EphemeralCallback) -> str:
102
+ """Convenience method to subscribe to a room channel."""
103
+ return await self.subscribe(f"room:{room_id}", callback)
104
+
105
+ async def close(self) -> None:
106
+ """Clean up resources.
107
+
108
+ Override this method in subclasses that need cleanup.
109
+ The default implementation does nothing.
110
+ """
111
+ return None