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,183 @@
1
+ """Twilio RCS provider — sends RCS messages via the Twilio REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from pydantic import BaseModel, SecretStr
8
+
9
+ from roomkit.models.delivery import InboundMessage
10
+ from roomkit.models.event import RoomEvent
11
+ from roomkit.providers.rcs.base import RCSDeliveryResult, RCSProvider
12
+ from roomkit.providers.sms.meta import (
13
+ build_inbound_content,
14
+ extract_media_urls,
15
+ extract_text_body,
16
+ )
17
+
18
+ if TYPE_CHECKING:
19
+ import httpx
20
+
21
+
22
+ class TwilioRCSConfig(BaseModel):
23
+ """Twilio RCS provider configuration."""
24
+
25
+ account_sid: str
26
+ auth_token: SecretStr
27
+ messaging_service_sid: str # Required for RCS (must be RCS-enabled)
28
+ timeout: float = 10.0
29
+
30
+ @property
31
+ def api_url(self) -> str:
32
+ return f"https://api.twilio.com/2010-04-01/Accounts/{self.account_sid}/Messages.json"
33
+
34
+
35
+ class TwilioRCSProvider(RCSProvider):
36
+ """RCS provider using the Twilio REST API.
37
+
38
+ Twilio RCS uses the same Messages API as SMS, but requires an RCS-enabled
39
+ Messaging Service. When the recipient doesn't support RCS, Twilio can
40
+ automatically fall back to SMS (unless disabled via fallback=False).
41
+ """
42
+
43
+ def __init__(self, config: TwilioRCSConfig) -> None:
44
+ try:
45
+ import httpx as _httpx
46
+ except ImportError as exc:
47
+ raise ImportError(
48
+ "httpx is required for TwilioRCSProvider. "
49
+ "Install it with: pip install roomkit[httpx]"
50
+ ) from exc
51
+ self._config = config
52
+ self._httpx = _httpx
53
+ self._client: httpx.AsyncClient = _httpx.AsyncClient(timeout=config.timeout)
54
+
55
+ @property
56
+ def sender_id(self) -> str:
57
+ return self._config.messaging_service_sid
58
+
59
+ async def send(
60
+ self,
61
+ event: RoomEvent,
62
+ to: str,
63
+ *,
64
+ fallback: bool = True,
65
+ ) -> RCSDeliveryResult:
66
+ """Send an RCS message via Twilio.
67
+
68
+ Args:
69
+ event: The room event containing the message content.
70
+ to: Recipient phone number (E.164 format).
71
+ fallback: If True, allow SMS fallback. If False, RCS only.
72
+
73
+ Returns:
74
+ Result with delivery info including channel used.
75
+ """
76
+ content = event.content
77
+ body = extract_text_body(content)
78
+ media_urls = extract_media_urls(content)
79
+
80
+ if not body and not media_urls:
81
+ return RCSDeliveryResult(success=False, error="empty_message")
82
+
83
+ auth = (self._config.account_sid, self._config.auth_token.get_secret_value())
84
+
85
+ # Twilio expects form-encoded data
86
+ data: dict[str, str] = {
87
+ "MessagingServiceSid": self._config.messaging_service_sid,
88
+ }
89
+
90
+ # For RCS-only (no fallback), prefix "to" with "rcs:"
91
+ if fallback:
92
+ data["To"] = to
93
+ else:
94
+ data["To"] = f"rcs:{to}"
95
+
96
+ if body:
97
+ data["Body"] = body
98
+
99
+ # Add media URLs (Twilio supports up to 10)
100
+ for i, url in enumerate(media_urls[:10]):
101
+ data[f"MediaUrl{i}"] = url
102
+
103
+ try:
104
+ resp = await self._client.post(
105
+ self._config.api_url,
106
+ auth=auth,
107
+ data=data,
108
+ )
109
+ resp.raise_for_status()
110
+ response_data = resp.json()
111
+ except self._httpx.TimeoutException:
112
+ return RCSDeliveryResult(success=False, error="timeout")
113
+ except self._httpx.HTTPStatusError as exc:
114
+ status = exc.response.status_code
115
+ if status == 401:
116
+ return RCSDeliveryResult(success=False, error="auth_error")
117
+ if status == 429:
118
+ return RCSDeliveryResult(success=False, error="rate_limit")
119
+ if status == 400:
120
+ try:
121
+ error_data = exc.response.json()
122
+ error_code = error_data.get("code", "invalid_request")
123
+ return RCSDeliveryResult(
124
+ success=False,
125
+ error=f"twilio_{error_code}",
126
+ metadata={"message": error_data.get("message", "")},
127
+ )
128
+ except Exception:
129
+ return RCSDeliveryResult(success=False, error="invalid_request")
130
+ return RCSDeliveryResult(success=False, error=f"http_{status}")
131
+ except self._httpx.HTTPError as exc:
132
+ return RCSDeliveryResult(success=False, error=str(exc))
133
+
134
+ # Twilio returns the channel used in the response
135
+ # For now, we assume RCS unless we detect fallback from status callback
136
+ return RCSDeliveryResult(
137
+ success=True,
138
+ provider_message_id=response_data.get("sid"),
139
+ channel_used="rcs",
140
+ fallback=False,
141
+ )
142
+
143
+ async def close(self) -> None:
144
+ await self._client.aclose()
145
+
146
+
147
+ def parse_twilio_rcs_webhook(
148
+ payload: dict[str, Any],
149
+ channel_id: str,
150
+ ) -> InboundMessage:
151
+ """Convert a Twilio RCS webhook POST body into an InboundMessage.
152
+
153
+ Note: Twilio sends webhooks as form-encoded data. Convert to dict first:
154
+ payload = dict(await request.form())
155
+ """
156
+ body = payload.get("Body", "")
157
+
158
+ media: list[dict[str, str | None]] = []
159
+ num_media = int(payload.get("NumMedia", "0"))
160
+ for i in range(num_media):
161
+ url = payload.get(f"MediaUrl{i}")
162
+ if url:
163
+ media.append(
164
+ {
165
+ "url": url,
166
+ "mime_type": payload.get(f"MediaContentType{i}"),
167
+ }
168
+ )
169
+
170
+ return InboundMessage(
171
+ channel_id=channel_id,
172
+ sender_id=payload.get("From", ""),
173
+ content=build_inbound_content(body, media),
174
+ external_id=payload.get("MessageSid"),
175
+ idempotency_key=payload.get("MessageSid"),
176
+ metadata={
177
+ "to": payload.get("To", ""),
178
+ "account_sid": payload.get("AccountSid", ""),
179
+ "num_media": payload.get("NumMedia", "0"),
180
+ "channel": payload.get("Channel", "rcs"), # RCS-specific
181
+ "messaging_service_sid": payload.get("MessagingServiceSid", ""),
182
+ },
183
+ )
@@ -0,0 +1,200 @@
1
+ """Twilio SMS provider — sends SMS via the Twilio 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.sms.base import SMSProvider
13
+ from roomkit.providers.sms.meta import (
14
+ build_inbound_content,
15
+ extract_media_urls,
16
+ extract_text_body,
17
+ )
18
+ from roomkit.providers.twilio.config import TwilioConfig
19
+
20
+ if TYPE_CHECKING:
21
+ import httpx
22
+
23
+
24
+ class TwilioSMSProvider(SMSProvider):
25
+ """SMS provider using the Twilio REST API."""
26
+
27
+ def __init__(self, config: TwilioConfig) -> None:
28
+ try:
29
+ import httpx as _httpx
30
+ except ImportError as exc:
31
+ raise ImportError(
32
+ "httpx is required for TwilioSMSProvider. "
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
+ # Twilio uses HTTP Basic auth
54
+ auth = (self._config.account_sid, self._config.auth_token.get_secret_value())
55
+
56
+ # Twilio expects form-encoded data
57
+ data: dict[str, str] = {
58
+ "To": to,
59
+ }
60
+
61
+ if body:
62
+ data["Body"] = body
63
+
64
+ # Add media URLs (Twilio supports up to 10)
65
+ for i, url in enumerate(media_urls[:10]):
66
+ data[f"MediaUrl{i}"] = url
67
+
68
+ # Use MessagingServiceSid if provided, otherwise use From number
69
+ if self._config.messaging_service_sid:
70
+ data["MessagingServiceSid"] = self._config.messaging_service_sid
71
+ else:
72
+ data["From"] = from_number
73
+
74
+ try:
75
+ resp = await self._client.post(
76
+ self._config.api_url,
77
+ auth=auth,
78
+ data=data,
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 to extract Twilio error code
92
+ try:
93
+ error_data = exc.response.json()
94
+ error_code = error_data.get("code", "invalid_request")
95
+ return ProviderResult(
96
+ success=False,
97
+ error=f"twilio_{error_code}",
98
+ metadata={"message": error_data.get("message", "")},
99
+ )
100
+ except Exception:
101
+ return ProviderResult(success=False, error="invalid_request")
102
+ return ProviderResult(success=False, error=f"http_{status}")
103
+ except self._httpx.HTTPError as exc:
104
+ return ProviderResult(success=False, error=str(exc))
105
+
106
+ return ProviderResult(
107
+ success=True,
108
+ provider_message_id=response_data.get("sid"),
109
+ )
110
+
111
+ def verify_signature(
112
+ self,
113
+ payload: bytes,
114
+ signature: str,
115
+ timestamp: str | None = None,
116
+ url: str | None = None,
117
+ ) -> bool:
118
+ """Verify a Twilio webhook signature using HMAC-SHA1.
119
+
120
+ Args:
121
+ payload: Raw request body bytes (form-encoded).
122
+ signature: Value of the ``X-Twilio-Signature`` header.
123
+ timestamp: Not used by Twilio (included for interface compatibility).
124
+ url: The full URL that Twilio called (required for verification).
125
+
126
+ Returns:
127
+ True if the signature is valid, False otherwise.
128
+ """
129
+ if not url:
130
+ return False
131
+
132
+ # Parse form-encoded payload and sort parameters
133
+ try:
134
+ from urllib.parse import unquote_plus
135
+
136
+ pairs = payload.decode().split("&")
137
+ params = dict(pair.split("=", 1) for pair in pairs if "=" in pair)
138
+ # URL decode the values
139
+ params = {k: unquote_plus(v) for k, v in params.items()}
140
+ except Exception:
141
+ return False
142
+
143
+ # Build the validation string: URL + sorted params
144
+ validation_string = url
145
+ for key in sorted(params.keys()):
146
+ validation_string += key + params[key]
147
+
148
+ # Compute HMAC-SHA1
149
+ expected_sig = base64.b64encode(
150
+ hmac.new(
151
+ self._config.auth_token.get_secret_value().encode(),
152
+ validation_string.encode(),
153
+ hashlib.sha1,
154
+ ).digest()
155
+ ).decode()
156
+
157
+ return hmac.compare_digest(expected_sig, signature)
158
+
159
+ async def close(self) -> None:
160
+ await self._client.aclose()
161
+
162
+
163
+ def parse_twilio_webhook(
164
+ payload: dict[str, Any],
165
+ channel_id: str,
166
+ ) -> InboundMessage:
167
+ """Convert a Twilio webhook POST body into an InboundMessage.
168
+
169
+ Note: Twilio sends webhooks as form-encoded data. Convert to dict first:
170
+ payload = dict(await request.form())
171
+ """
172
+ body = payload.get("Body", "")
173
+
174
+ media: list[dict[str, str | None]] = []
175
+ num_media = int(payload.get("NumMedia", "0"))
176
+ for i in range(num_media):
177
+ url = payload.get(f"MediaUrl{i}")
178
+ if url:
179
+ media.append(
180
+ {
181
+ "url": url,
182
+ "mime_type": payload.get(f"MediaContentType{i}"),
183
+ }
184
+ )
185
+
186
+ return InboundMessage(
187
+ channel_id=channel_id,
188
+ sender_id=payload.get("From", ""),
189
+ content=build_inbound_content(body, media),
190
+ external_id=payload.get("MessageSid"),
191
+ idempotency_key=payload.get("MessageSid"),
192
+ metadata={
193
+ "to": payload.get("To", ""),
194
+ "account_sid": payload.get("AccountSid", ""),
195
+ "num_media": payload.get("NumMedia", "0"),
196
+ "from_city": payload.get("FromCity"),
197
+ "from_state": payload.get("FromState"),
198
+ "from_country": payload.get("FromCountry"),
199
+ },
200
+ )
@@ -0,0 +1,15 @@
1
+ """VoiceMeUp provider."""
2
+
3
+ from roomkit.providers.voicemeup.config import VoiceMeUpConfig
4
+ from roomkit.providers.voicemeup.sms import (
5
+ VoiceMeUpSMSProvider,
6
+ configure_voicemeup_mms,
7
+ parse_voicemeup_webhook,
8
+ )
9
+
10
+ __all__ = [
11
+ "VoiceMeUpConfig",
12
+ "VoiceMeUpSMSProvider",
13
+ "configure_voicemeup_mms",
14
+ "parse_voicemeup_webhook",
15
+ ]
@@ -0,0 +1,21 @@
1
+ """VoiceMeUp provider configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, SecretStr
6
+
7
+
8
+ class VoiceMeUpConfig(BaseModel):
9
+ """VoiceMeUp SMS provider configuration."""
10
+
11
+ username: str
12
+ auth_token: SecretStr
13
+ from_number: str
14
+ environment: str = "production"
15
+ timeout: float = 10.0
16
+
17
+ @property
18
+ def base_url(self) -> str:
19
+ if self.environment == "sandbox":
20
+ return "https://dev-clients.voicemeup.com/api/v1.1/json/"
21
+ return "https://clients.voicemeup.com/api/v1.1/json/"