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,78 @@
1
+ """Mock RCS provider for testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from roomkit.models.event import RoomEvent
8
+ from roomkit.providers.rcs.base import RCSDeliveryResult, RCSProvider
9
+
10
+
11
+ class MockRCSProvider(RCSProvider):
12
+ """Mock RCS provider for testing."""
13
+
14
+ def __init__(
15
+ self,
16
+ sender_id: str = "mock_rcs_agent",
17
+ *,
18
+ simulate_fallback: bool = False,
19
+ simulate_failure: bool = False,
20
+ ) -> None:
21
+ """Initialize mock provider.
22
+
23
+ Args:
24
+ sender_id: Mock sender/agent ID.
25
+ simulate_fallback: If True, simulate SMS fallback on sends.
26
+ simulate_failure: If True, simulate send failures.
27
+ """
28
+ self._sender_id = sender_id
29
+ self._simulate_fallback = simulate_fallback
30
+ self._simulate_failure = simulate_failure
31
+ self.calls: list[dict[str, Any]] = []
32
+ self._message_counter = 0
33
+
34
+ @property
35
+ def sender_id(self) -> str:
36
+ return self._sender_id
37
+
38
+ async def send(
39
+ self,
40
+ event: RoomEvent,
41
+ to: str,
42
+ *,
43
+ fallback: bool = True,
44
+ ) -> RCSDeliveryResult:
45
+ self._message_counter += 1
46
+ self.calls.append(
47
+ {
48
+ "event": event,
49
+ "to": to,
50
+ "fallback": fallback,
51
+ }
52
+ )
53
+
54
+ if self._simulate_failure:
55
+ return RCSDeliveryResult(
56
+ success=False,
57
+ error="simulated_failure",
58
+ )
59
+
60
+ # Simulate fallback to SMS
61
+ if self._simulate_fallback and fallback:
62
+ return RCSDeliveryResult(
63
+ success=True,
64
+ provider_message_id=f"mock_sms_{self._message_counter}",
65
+ channel_used="sms",
66
+ fallback=True,
67
+ )
68
+
69
+ return RCSDeliveryResult(
70
+ success=True,
71
+ provider_message_id=f"mock_rcs_{self._message_counter}",
72
+ channel_used="rcs",
73
+ fallback=False,
74
+ )
75
+
76
+ async def check_capability(self, phone_number: str) -> bool:
77
+ """Mock capability check - returns opposite of simulate_fallback."""
78
+ return not self._simulate_fallback
@@ -0,0 +1,5 @@
1
+ """SendGrid provider."""
2
+
3
+ from roomkit.providers.sendgrid.config import SendGridConfig
4
+
5
+ __all__ = ["SendGridConfig"]
@@ -0,0 +1,13 @@
1
+ """SendGrid provider configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, SecretStr
6
+
7
+
8
+ class SendGridConfig(BaseModel):
9
+ """SendGrid email provider configuration."""
10
+
11
+ api_key: SecretStr
12
+ from_email: str
13
+ from_name: str | None = None
@@ -0,0 +1,6 @@
1
+ """Sinch provider."""
2
+
3
+ from roomkit.providers.sinch.config import SinchConfig
4
+ from roomkit.providers.sinch.sms import SinchSMSProvider, parse_sinch_webhook
5
+
6
+ __all__ = ["SinchConfig", "SinchSMSProvider", "parse_sinch_webhook"]
@@ -0,0 +1,22 @@
1
+ """Sinch provider configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, SecretStr
8
+
9
+
10
+ class SinchConfig(BaseModel):
11
+ """Sinch SMS provider configuration."""
12
+
13
+ service_plan_id: str
14
+ api_token: SecretStr
15
+ from_number: str
16
+ region: Literal["us", "eu", "au", "br", "ca"] = "us"
17
+ webhook_secret: SecretStr | None = None
18
+ timeout: float = 10.0
19
+
20
+ @property
21
+ def api_url(self) -> str:
22
+ return f"https://{self.region}.sms.api.sinch.com/xms/v1/{self.service_plan_id}/batches"
@@ -0,0 +1,192 @@
1
+ """Sinch SMS provider — sends SMS via the Sinch REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from roomkit.models.delivery import InboundMessage, ProviderResult
11
+ from roomkit.models.event import RoomEvent
12
+ from roomkit.providers.sinch.config import SinchConfig
13
+ from roomkit.providers.sms.base import SMSProvider
14
+ from roomkit.providers.sms.meta import (
15
+ build_inbound_content,
16
+ extract_media_urls,
17
+ extract_text_body,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ import httpx
22
+
23
+
24
+ class SinchSMSProvider(SMSProvider):
25
+ """SMS provider using the Sinch REST API."""
26
+
27
+ def __init__(self, config: SinchConfig) -> None:
28
+ try:
29
+ import httpx as _httpx
30
+ except ImportError as exc:
31
+ raise ImportError(
32
+ "httpx is required for SinchSMSProvider. "
33
+ "Install it with: pip install roomkit[httpx]"
34
+ ) from exc
35
+ self._config = config
36
+ self._httpx = _httpx
37
+ self._client: httpx.AsyncClient = _httpx.AsyncClient(timeout=config.timeout)
38
+
39
+ @property
40
+ def from_number(self) -> str:
41
+ return self._config.from_number
42
+
43
+ async def send(self, event: RoomEvent, to: str, from_: str | None = None) -> ProviderResult:
44
+ content = event.content
45
+ body = extract_text_body(content)
46
+ media_urls = extract_media_urls(content)
47
+
48
+ if not body and not media_urls:
49
+ return ProviderResult(success=False, error="empty_message")
50
+
51
+ from_number = from_ or self._config.from_number
52
+
53
+ headers = {
54
+ "Authorization": f"Bearer {self._config.api_token.get_secret_value()}",
55
+ "Content-Type": "application/json",
56
+ }
57
+
58
+ # Sinch expects 'to' as an array
59
+ payload: dict[str, Any] = {
60
+ "from": from_number,
61
+ "to": [to],
62
+ }
63
+
64
+ if media_urls:
65
+ # Sinch MMS: type mt_media, body contains url and optional message
66
+ payload["type"] = "mt_media"
67
+ media_body: dict[str, str] = {"url": media_urls[0]}
68
+ if body:
69
+ media_body["message"] = body
70
+ payload["body"] = media_body
71
+ else:
72
+ payload["body"] = body
73
+
74
+ try:
75
+ resp = await self._client.post(
76
+ self._config.api_url,
77
+ headers=headers,
78
+ json=payload,
79
+ )
80
+ resp.raise_for_status()
81
+ response_data = resp.json()
82
+ except self._httpx.TimeoutException:
83
+ return ProviderResult(success=False, error="timeout")
84
+ except self._httpx.HTTPStatusError as exc:
85
+ status = exc.response.status_code
86
+ if status == 401:
87
+ return ProviderResult(success=False, error="auth_error")
88
+ if status == 429:
89
+ return ProviderResult(success=False, error="rate_limit")
90
+ if status == 400:
91
+ try:
92
+ error_data = exc.response.json()
93
+ error_code = error_data.get("code", "invalid_request")
94
+ return ProviderResult(
95
+ success=False,
96
+ error=f"sinch_{error_code}",
97
+ metadata={"message": error_data.get("text", "")},
98
+ )
99
+ except Exception:
100
+ return ProviderResult(success=False, error="invalid_request")
101
+ return ProviderResult(success=False, error=f"http_{status}")
102
+ except self._httpx.HTTPError as exc:
103
+ return ProviderResult(success=False, error=str(exc))
104
+
105
+ return ProviderResult(
106
+ success=True,
107
+ provider_message_id=response_data.get("id"),
108
+ )
109
+
110
+ def verify_signature(
111
+ self,
112
+ payload: bytes,
113
+ signature: str,
114
+ timestamp: str | None = None,
115
+ ) -> bool:
116
+ """Verify a Sinch webhook signature using HMAC-SHA1.
117
+
118
+ Args:
119
+ payload: Raw request body bytes (JSON).
120
+ signature: Value of the ``X-Sinch-Signature`` header.
121
+ timestamp: Not used by Sinch (included for interface compatibility).
122
+
123
+ Returns:
124
+ True if the signature is valid, False otherwise.
125
+
126
+ Raises:
127
+ ValueError: If webhook_secret was not provided in config.
128
+ """
129
+ if not self._config.webhook_secret:
130
+ raise ValueError(
131
+ "webhook_secret must be provided in SinchConfig for signature verification"
132
+ )
133
+
134
+ try:
135
+ expected_sig = base64.b64encode(
136
+ hmac.new(
137
+ self._config.webhook_secret.get_secret_value().encode(),
138
+ payload,
139
+ hashlib.sha1,
140
+ ).digest()
141
+ ).decode()
142
+ return hmac.compare_digest(expected_sig, signature)
143
+ except Exception:
144
+ return False
145
+
146
+ async def close(self) -> None:
147
+ await self._client.aclose()
148
+
149
+
150
+ def parse_sinch_webhook(
151
+ payload: dict[str, Any],
152
+ channel_id: str,
153
+ ) -> InboundMessage:
154
+ """Convert a Sinch SMS webhook POST body into an InboundMessage.
155
+
156
+ Sinch inbound SMS webhook structure:
157
+ {
158
+ "id": "message-id",
159
+ "from": "+15551234567",
160
+ "to": "12345",
161
+ "body": "Message text",
162
+ "received_at": "2026-01-28T12:00:00.000Z",
163
+ "operator_id": "...",
164
+ "media": [{"url": "...", "mimeType": "image/jpeg"}],
165
+ ...
166
+ }
167
+ """
168
+ body = payload.get("body", "")
169
+
170
+ media: list[dict[str, str | None]] = []
171
+ for m in payload.get("media", []):
172
+ if url := m.get("url"):
173
+ media.append(
174
+ {
175
+ "url": url,
176
+ "mime_type": m.get("mimeType"),
177
+ }
178
+ )
179
+
180
+ return InboundMessage(
181
+ channel_id=channel_id,
182
+ sender_id=payload.get("from", ""),
183
+ content=build_inbound_content(body, media),
184
+ external_id=payload.get("id"),
185
+ idempotency_key=payload.get("id"),
186
+ metadata={
187
+ "to": payload.get("to", ""),
188
+ "received_at": payload.get("received_at"),
189
+ "operator_id": payload.get("operator_id"),
190
+ "client_reference": payload.get("client_reference"),
191
+ },
192
+ )
@@ -0,0 +1,15 @@
1
+ """SMS providers."""
2
+
3
+ from roomkit.providers.sms.base import SMSProvider
4
+ from roomkit.providers.sms.meta import WebhookMeta, extract_sms_meta
5
+ from roomkit.providers.sms.mock import MockSMSProvider
6
+ from roomkit.providers.sms.phone import is_valid_phone, normalize_phone
7
+
8
+ __all__ = [
9
+ "MockSMSProvider",
10
+ "SMSProvider",
11
+ "WebhookMeta",
12
+ "extract_sms_meta",
13
+ "is_valid_phone",
14
+ "normalize_phone",
15
+ ]
@@ -0,0 +1,67 @@
1
+ """Abstract base class for SMS 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 SMSProvider(ABC):
13
+ """SMS delivery provider."""
14
+
15
+ @property
16
+ def name(self) -> str:
17
+ """Provider name (e.g. 'twilio', 'sinch')."""
18
+ return self.__class__.__name__
19
+
20
+ @property
21
+ @abstractmethod
22
+ def from_number(self) -> str:
23
+ """Default sender phone number."""
24
+ ...
25
+
26
+ @abstractmethod
27
+ async def send(self, event: RoomEvent, to: str, from_: str | None = None) -> ProviderResult:
28
+ """Send an SMS message.
29
+
30
+ Args:
31
+ event: The room event containing the message content.
32
+ to: Recipient phone number (E.164 format).
33
+ from_: Sender phone number override. Defaults to ``from_number``.
34
+
35
+ Returns:
36
+ Result with provider-specific delivery metadata.
37
+ """
38
+ ...
39
+
40
+ async def parse_webhook(self, payload: dict[str, Any]) -> InboundMessage:
41
+ """Parse an inbound webhook payload into an InboundMessage."""
42
+ raise NotImplementedError
43
+
44
+ def verify_signature(
45
+ self,
46
+ payload: bytes,
47
+ signature: str,
48
+ timestamp: str | None = None,
49
+ ) -> bool:
50
+ """Verify that a webhook payload was signed by the provider.
51
+
52
+ Args:
53
+ payload: Raw request body bytes.
54
+ signature: Signature header value from the webhook request.
55
+ timestamp: Timestamp header value (required by some providers).
56
+
57
+ Returns:
58
+ True if the signature is valid, False otherwise.
59
+
60
+ Raises:
61
+ NotImplementedError: If the provider does not support signature
62
+ verification.
63
+ """
64
+ raise NotImplementedError(f"{self.name} does not support webhook signature verification")
65
+
66
+ async def close(self) -> None: # noqa: B027
67
+ """Release resources. Override in subclasses that hold connections."""