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,352 @@
|
|
|
1
|
+
"""Telnyx RCS provider — sends RCS messages via the Telnyx REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, SecretStr
|
|
9
|
+
|
|
10
|
+
from roomkit.models.delivery import InboundMessage
|
|
11
|
+
from roomkit.models.event import RoomEvent
|
|
12
|
+
from roomkit.providers.rcs.base import RCSDeliveryResult, RCSProvider
|
|
13
|
+
from roomkit.providers.sms.meta import (
|
|
14
|
+
build_inbound_content,
|
|
15
|
+
extract_media_urls,
|
|
16
|
+
extract_text_body,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
_API_URL = "https://api.telnyx.com/v2/messages"
|
|
23
|
+
_RCS_CAPABILITY_URL = "https://api.telnyx.com/v2/messaging/rcs/capabilities"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TelnyxRCSConfig(BaseModel):
|
|
27
|
+
"""Telnyx RCS provider configuration.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
api_key: Telnyx API key (v2 key starting with KEY...).
|
|
31
|
+
agent_id: RCS agent ID (obtained after agent onboarding/brand approval).
|
|
32
|
+
messaging_profile_id: Optional messaging profile ID for webhooks.
|
|
33
|
+
timeout: HTTP request timeout in seconds.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
api_key: SecretStr
|
|
37
|
+
agent_id: str
|
|
38
|
+
messaging_profile_id: str | None = None
|
|
39
|
+
timeout: float = 10.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TelnyxRCSProvider(RCSProvider):
|
|
43
|
+
"""RCS provider using the Telnyx REST API.
|
|
44
|
+
|
|
45
|
+
Telnyx RCS uses the same /v2/messages endpoint as SMS, but requires an
|
|
46
|
+
RCS agent_id as the sender. When the recipient doesn't support RCS,
|
|
47
|
+
messages can fall back to SMS (if fallback=True).
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
config = TelnyxRCSConfig(
|
|
51
|
+
api_key="KEY...",
|
|
52
|
+
agent_id="your-rcs-agent-id",
|
|
53
|
+
)
|
|
54
|
+
provider = TelnyxRCSProvider(config)
|
|
55
|
+
result = await provider.send(event, to="+14155551234")
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, config: TelnyxRCSConfig, public_key: str | None = None) -> None:
|
|
59
|
+
"""Initialize the Telnyx RCS provider.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
config: Telnyx RCS configuration.
|
|
63
|
+
public_key: Telnyx public key for webhook signature verification.
|
|
64
|
+
Found in Mission Control Portal > Keys & Credentials > Public Key.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
import httpx as _httpx
|
|
68
|
+
except ImportError as exc:
|
|
69
|
+
raise ImportError(
|
|
70
|
+
"httpx is required for TelnyxRCSProvider. "
|
|
71
|
+
"Install it with: pip install roomkit[httpx]"
|
|
72
|
+
) from exc
|
|
73
|
+
self._config = config
|
|
74
|
+
self._public_key = public_key
|
|
75
|
+
self._httpx = _httpx
|
|
76
|
+
self._client: httpx.AsyncClient = _httpx.AsyncClient(timeout=config.timeout)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def sender_id(self) -> str:
|
|
80
|
+
"""RCS agent ID used as sender."""
|
|
81
|
+
return self._config.agent_id
|
|
82
|
+
|
|
83
|
+
async def send(
|
|
84
|
+
self,
|
|
85
|
+
event: RoomEvent,
|
|
86
|
+
to: str,
|
|
87
|
+
*,
|
|
88
|
+
fallback: bool = True,
|
|
89
|
+
) -> RCSDeliveryResult:
|
|
90
|
+
"""Send an RCS message via Telnyx.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
event: The room event containing the message content.
|
|
94
|
+
to: Recipient phone number (E.164 format).
|
|
95
|
+
fallback: If True, allow SMS fallback. If False, RCS only.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Result with delivery info including channel used.
|
|
99
|
+
"""
|
|
100
|
+
content = event.content
|
|
101
|
+
body = extract_text_body(content)
|
|
102
|
+
media_urls = extract_media_urls(content)
|
|
103
|
+
|
|
104
|
+
if not body and not media_urls:
|
|
105
|
+
return RCSDeliveryResult(success=False, error="empty_message")
|
|
106
|
+
|
|
107
|
+
headers = {
|
|
108
|
+
"Authorization": f"Bearer {self._config.api_key.get_secret_value()}",
|
|
109
|
+
"Content-Type": "application/json",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
payload: dict[str, Any] = {
|
|
113
|
+
"from": self._config.agent_id,
|
|
114
|
+
"to": to,
|
|
115
|
+
"type": "RCS",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if body:
|
|
119
|
+
payload["text"] = body
|
|
120
|
+
|
|
121
|
+
if media_urls:
|
|
122
|
+
payload["media_urls"] = media_urls
|
|
123
|
+
|
|
124
|
+
if self._config.messaging_profile_id:
|
|
125
|
+
payload["messaging_profile_id"] = self._config.messaging_profile_id
|
|
126
|
+
|
|
127
|
+
# If no fallback, set auto_detect to false to force RCS-only
|
|
128
|
+
if not fallback:
|
|
129
|
+
payload["auto_detect"] = False
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
resp = await self._client.post(_API_URL, headers=headers, json=payload)
|
|
133
|
+
resp.raise_for_status()
|
|
134
|
+
data = resp.json()
|
|
135
|
+
except self._httpx.TimeoutException:
|
|
136
|
+
return RCSDeliveryResult(success=False, error="timeout")
|
|
137
|
+
except self._httpx.HTTPStatusError as exc:
|
|
138
|
+
status = exc.response.status_code
|
|
139
|
+
if status == 401:
|
|
140
|
+
return RCSDeliveryResult(success=False, error="auth_error")
|
|
141
|
+
if status == 429:
|
|
142
|
+
return RCSDeliveryResult(success=False, error="rate_limit")
|
|
143
|
+
if status == 400:
|
|
144
|
+
try:
|
|
145
|
+
error_data = exc.response.json()
|
|
146
|
+
errors = error_data.get("errors", [])
|
|
147
|
+
if errors:
|
|
148
|
+
error_code = errors[0].get("code", "invalid_request")
|
|
149
|
+
error_msg = errors[0].get("detail", "")
|
|
150
|
+
else:
|
|
151
|
+
error_code = "invalid_request"
|
|
152
|
+
error_msg = ""
|
|
153
|
+
return RCSDeliveryResult(
|
|
154
|
+
success=False,
|
|
155
|
+
error=f"telnyx_{error_code}",
|
|
156
|
+
metadata={"message": error_msg},
|
|
157
|
+
)
|
|
158
|
+
except Exception:
|
|
159
|
+
return RCSDeliveryResult(success=False, error="invalid_request")
|
|
160
|
+
return RCSDeliveryResult(success=False, error=f"http_{status}")
|
|
161
|
+
except self._httpx.HTTPError as exc:
|
|
162
|
+
return RCSDeliveryResult(success=False, error=str(exc))
|
|
163
|
+
|
|
164
|
+
return self._parse_response(data)
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def _parse_response(data: dict[str, Any]) -> RCSDeliveryResult:
|
|
168
|
+
"""Parse Telnyx API response into RCSDeliveryResult."""
|
|
169
|
+
message_data = data.get("data", {})
|
|
170
|
+
message_id = message_data.get("id")
|
|
171
|
+
message_type = message_data.get("type", "RCS")
|
|
172
|
+
|
|
173
|
+
# Determine if fallback occurred based on message type
|
|
174
|
+
channel_used = "rcs" if message_type == "RCS" else "sms"
|
|
175
|
+
fallback_occurred = channel_used == "sms"
|
|
176
|
+
|
|
177
|
+
return RCSDeliveryResult(
|
|
178
|
+
success=True,
|
|
179
|
+
provider_message_id=message_id,
|
|
180
|
+
channel_used=channel_used,
|
|
181
|
+
fallback=fallback_occurred,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async def check_capability(self, phone_number: str) -> bool:
|
|
185
|
+
"""Check if a phone number supports RCS.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
phone_number: Phone number to check (E.164 format).
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
True if the number supports RCS, False otherwise.
|
|
192
|
+
"""
|
|
193
|
+
headers = {
|
|
194
|
+
"Authorization": f"Bearer {self._config.api_key.get_secret_value()}",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
url = f"{_RCS_CAPABILITY_URL}/{self._config.agent_id}/{phone_number}"
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
resp = await self._client.get(url, headers=headers)
|
|
201
|
+
resp.raise_for_status()
|
|
202
|
+
data = resp.json()
|
|
203
|
+
except self._httpx.HTTPError:
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
# Check if RCS is supported
|
|
207
|
+
capabilities = data.get("data", {})
|
|
208
|
+
return bool(capabilities.get("rcs_enabled", False))
|
|
209
|
+
|
|
210
|
+
def verify_signature(
|
|
211
|
+
self,
|
|
212
|
+
payload: bytes,
|
|
213
|
+
signature: str,
|
|
214
|
+
timestamp: str | None = None,
|
|
215
|
+
) -> bool:
|
|
216
|
+
"""Verify a Telnyx webhook signature using ED25519.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
payload: Raw request body bytes.
|
|
220
|
+
signature: Value of the ``Telnyx-Signature-Ed25519`` header.
|
|
221
|
+
timestamp: Value of the ``Telnyx-Timestamp`` header.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
True if the signature is valid, False otherwise.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
ValueError: If public_key was not provided to the constructor.
|
|
228
|
+
ImportError: If PyNaCl is not installed.
|
|
229
|
+
"""
|
|
230
|
+
if not self._public_key:
|
|
231
|
+
raise ValueError(
|
|
232
|
+
"public_key must be provided to TelnyxRCSProvider for signature verification"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if not timestamp:
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
from nacl.signing import VerifyKey
|
|
240
|
+
except ImportError as exc:
|
|
241
|
+
raise ImportError(
|
|
242
|
+
"PyNaCl is required for Telnyx signature verification. "
|
|
243
|
+
"Install it with: pip install pynacl"
|
|
244
|
+
) from exc
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
# Telnyx signs: timestamp|payload
|
|
248
|
+
signed_payload = f"{timestamp}|".encode() + payload
|
|
249
|
+
signature_bytes = base64.b64decode(signature)
|
|
250
|
+
public_key_bytes = base64.b64decode(self._public_key)
|
|
251
|
+
verify_key = VerifyKey(public_key_bytes)
|
|
252
|
+
verify_key.verify(signed_payload, signature_bytes)
|
|
253
|
+
return True
|
|
254
|
+
except Exception:
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
async def close(self) -> None:
|
|
258
|
+
"""Close the HTTP client."""
|
|
259
|
+
await self._client.aclose()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _is_telnyx_rcs_inbound(payload: dict[str, Any]) -> bool:
|
|
263
|
+
"""Check if a Telnyx RCS webhook is an inbound message (internal use)."""
|
|
264
|
+
event_data = payload.get("data", {})
|
|
265
|
+
event_type = str(event_data.get("event_type", ""))
|
|
266
|
+
direction = str(event_data.get("payload", {}).get("direction", ""))
|
|
267
|
+
|
|
268
|
+
return event_type == "message.received" and direction == "inbound"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def parse_telnyx_rcs_webhook(
|
|
272
|
+
payload: dict[str, Any],
|
|
273
|
+
channel_id: str,
|
|
274
|
+
*,
|
|
275
|
+
strict: bool = True,
|
|
276
|
+
) -> InboundMessage:
|
|
277
|
+
"""Convert a Telnyx RCS webhook POST body into an InboundMessage.
|
|
278
|
+
|
|
279
|
+
Telnyx RCS webhooks use JSON format (unlike SMS which can be form-encoded).
|
|
280
|
+
The webhook structure follows the same pattern as SMS but includes RCS-specific
|
|
281
|
+
fields like agent_id.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
payload: The webhook POST body as a dictionary.
|
|
285
|
+
channel_id: The channel ID to associate with the message.
|
|
286
|
+
strict: If True (default), raises ValueError for non-inbound webhooks.
|
|
287
|
+
Set to False to skip validation (not recommended).
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
An InboundMessage ready for process_inbound().
|
|
291
|
+
|
|
292
|
+
Raises:
|
|
293
|
+
ValueError: If strict=True and the webhook is not an inbound message.
|
|
294
|
+
"""
|
|
295
|
+
if strict and not _is_telnyx_rcs_inbound(payload):
|
|
296
|
+
event_type = payload.get("data", {}).get("event_type", "unknown")
|
|
297
|
+
direction = payload.get("data", {}).get("payload", {}).get("direction", "unknown")
|
|
298
|
+
raise ValueError(
|
|
299
|
+
f"Not an inbound message (event_type={event_type}, direction={direction}). "
|
|
300
|
+
f"Use extract_sms_meta() with meta.is_inbound to filter webhooks."
|
|
301
|
+
)
|
|
302
|
+
data = payload.get("data", {}).get("payload", {})
|
|
303
|
+
|
|
304
|
+
# Extract text content
|
|
305
|
+
body = data.get("text", "")
|
|
306
|
+
|
|
307
|
+
# Extract media (RCS supports rich media)
|
|
308
|
+
media: list[dict[str, str | None]] = []
|
|
309
|
+
for m in data.get("media", []):
|
|
310
|
+
if url := m.get("url"):
|
|
311
|
+
media.append(
|
|
312
|
+
{
|
|
313
|
+
"url": url,
|
|
314
|
+
"mime_type": m.get("content_type"),
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# RCS can also have user_file for file transfers
|
|
319
|
+
user_file = data.get("user_file", {}).get("payload", {})
|
|
320
|
+
if user_file and (file_uri := user_file.get("file_uri")):
|
|
321
|
+
media.append(
|
|
322
|
+
{
|
|
323
|
+
"url": file_uri,
|
|
324
|
+
"mime_type": user_file.get("mime_type"),
|
|
325
|
+
}
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# Get sender info
|
|
329
|
+
from_data = data.get("from", {})
|
|
330
|
+
sender = from_data.get("phone_number", "")
|
|
331
|
+
|
|
332
|
+
# Get recipient info (the RCS agent)
|
|
333
|
+
to_data = data.get("to", {})
|
|
334
|
+
agent_id = to_data.get("agent_id", "")
|
|
335
|
+
|
|
336
|
+
return InboundMessage(
|
|
337
|
+
channel_id=channel_id,
|
|
338
|
+
sender_id=sender,
|
|
339
|
+
content=build_inbound_content(body, media),
|
|
340
|
+
external_id=data.get("id"),
|
|
341
|
+
idempotency_key=data.get("id"),
|
|
342
|
+
metadata={
|
|
343
|
+
"agent_id": agent_id,
|
|
344
|
+
"agent_name": to_data.get("agent_name", ""),
|
|
345
|
+
"received_at": data.get("received_at"),
|
|
346
|
+
"type": data.get("type", "RCS"),
|
|
347
|
+
# RCS-specific: suggestion responses
|
|
348
|
+
"suggestion_response": data.get("suggestion_response"),
|
|
349
|
+
# RCS-specific: location sharing
|
|
350
|
+
"location": data.get("location"),
|
|
351
|
+
},
|
|
352
|
+
)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Telnyx SMS provider — sends SMS via the Telnyx REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from roomkit.models.delivery import InboundMessage, ProviderResult
|
|
9
|
+
from roomkit.models.event import RoomEvent
|
|
10
|
+
from roomkit.providers.sms.base import SMSProvider
|
|
11
|
+
from roomkit.providers.sms.meta import (
|
|
12
|
+
build_inbound_content,
|
|
13
|
+
extract_media_urls,
|
|
14
|
+
extract_text_body,
|
|
15
|
+
)
|
|
16
|
+
from roomkit.providers.telnyx.config import TelnyxConfig
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
_API_URL = "https://api.telnyx.com/v2/messages"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TelnyxSMSProvider(SMSProvider):
|
|
25
|
+
"""SMS provider using the Telnyx REST API."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: TelnyxConfig, public_key: str | None = None) -> None:
|
|
28
|
+
"""Initialize the Telnyx SMS provider.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
config: Telnyx configuration.
|
|
32
|
+
public_key: Telnyx public key for webhook signature verification.
|
|
33
|
+
Found in Mission Control Portal > Keys & Credentials > Public Key.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
import httpx as _httpx
|
|
37
|
+
except ImportError as exc:
|
|
38
|
+
raise ImportError(
|
|
39
|
+
"httpx is required for TelnyxSMSProvider. "
|
|
40
|
+
"Install it with: pip install roomkit[httpx]"
|
|
41
|
+
) from exc
|
|
42
|
+
self._config = config
|
|
43
|
+
self._public_key = public_key
|
|
44
|
+
self._httpx = _httpx
|
|
45
|
+
self._client: httpx.AsyncClient = _httpx.AsyncClient(timeout=config.timeout)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def from_number(self) -> str:
|
|
49
|
+
return self._config.from_number
|
|
50
|
+
|
|
51
|
+
async def send(self, event: RoomEvent, to: str, from_: str | None = None) -> ProviderResult:
|
|
52
|
+
content = event.content
|
|
53
|
+
body = extract_text_body(content)
|
|
54
|
+
media_urls = extract_media_urls(content)
|
|
55
|
+
|
|
56
|
+
if not body and not media_urls:
|
|
57
|
+
return ProviderResult(success=False, error="empty_message")
|
|
58
|
+
|
|
59
|
+
from_number = from_ or self._config.from_number
|
|
60
|
+
|
|
61
|
+
headers = {
|
|
62
|
+
"Authorization": f"Bearer {self._config.api_key.get_secret_value()}",
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
payload: dict[str, Any] = {
|
|
67
|
+
"from": from_number,
|
|
68
|
+
"to": to,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if body:
|
|
72
|
+
payload["text"] = body
|
|
73
|
+
|
|
74
|
+
if media_urls:
|
|
75
|
+
payload["media_urls"] = media_urls
|
|
76
|
+
|
|
77
|
+
if self._config.messaging_profile_id:
|
|
78
|
+
payload["messaging_profile_id"] = self._config.messaging_profile_id
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
resp = await self._client.post(_API_URL, headers=headers, json=payload)
|
|
82
|
+
resp.raise_for_status()
|
|
83
|
+
data = resp.json()
|
|
84
|
+
except self._httpx.TimeoutException:
|
|
85
|
+
return ProviderResult(success=False, error="timeout")
|
|
86
|
+
except self._httpx.HTTPStatusError as exc:
|
|
87
|
+
status = exc.response.status_code
|
|
88
|
+
if status == 401:
|
|
89
|
+
return ProviderResult(success=False, error="auth_error")
|
|
90
|
+
if status == 429:
|
|
91
|
+
return ProviderResult(success=False, error="rate_limit")
|
|
92
|
+
if status == 400:
|
|
93
|
+
return ProviderResult(success=False, error="invalid_request")
|
|
94
|
+
return ProviderResult(success=False, error=f"http_{status}")
|
|
95
|
+
except self._httpx.HTTPError as exc:
|
|
96
|
+
return ProviderResult(success=False, error=str(exc))
|
|
97
|
+
|
|
98
|
+
return self._parse_response(data)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _parse_response(data: dict[str, Any]) -> ProviderResult:
|
|
102
|
+
message_data = data.get("data", {})
|
|
103
|
+
message_id = message_data.get("id")
|
|
104
|
+
return ProviderResult(success=True, provider_message_id=message_id)
|
|
105
|
+
|
|
106
|
+
async def close(self) -> None:
|
|
107
|
+
await self._client.aclose()
|
|
108
|
+
|
|
109
|
+
def verify_signature(
|
|
110
|
+
self,
|
|
111
|
+
payload: bytes,
|
|
112
|
+
signature: str,
|
|
113
|
+
timestamp: str | None = None,
|
|
114
|
+
) -> bool:
|
|
115
|
+
"""Verify a Telnyx webhook signature using ED25519.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
payload: Raw request body bytes.
|
|
119
|
+
signature: Value of the ``Telnyx-Signature-Ed25519`` header.
|
|
120
|
+
timestamp: Value of the ``Telnyx-Timestamp`` header.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
True if the signature is valid, False otherwise.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
ValueError: If public_key was not provided to the constructor.
|
|
127
|
+
ImportError: If PyNaCl is not installed.
|
|
128
|
+
"""
|
|
129
|
+
if not self._public_key:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
"public_key must be provided to TelnyxSMSProvider for signature verification"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if not timestamp:
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
from nacl.signing import VerifyKey
|
|
139
|
+
except ImportError as exc:
|
|
140
|
+
raise ImportError(
|
|
141
|
+
"PyNaCl is required for Telnyx signature verification. "
|
|
142
|
+
"Install it with: pip install pynacl"
|
|
143
|
+
) from exc
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
# Telnyx signs: timestamp|payload
|
|
147
|
+
signed_payload = f"{timestamp}|".encode() + payload
|
|
148
|
+
signature_bytes = base64.b64decode(signature)
|
|
149
|
+
public_key_bytes = base64.b64decode(self._public_key)
|
|
150
|
+
verify_key = VerifyKey(public_key_bytes)
|
|
151
|
+
verify_key.verify(signed_payload, signature_bytes)
|
|
152
|
+
return True
|
|
153
|
+
except Exception:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _is_telnyx_inbound(payload: dict[str, Any]) -> bool:
|
|
158
|
+
"""Check if a Telnyx webhook is an inbound message (internal use)."""
|
|
159
|
+
event_data = payload.get("data", {})
|
|
160
|
+
event_type = str(event_data.get("event_type", ""))
|
|
161
|
+
direction = str(event_data.get("payload", {}).get("direction", ""))
|
|
162
|
+
|
|
163
|
+
return event_type == "message.received" and direction == "inbound"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def parse_telnyx_webhook(
|
|
167
|
+
payload: dict[str, Any],
|
|
168
|
+
channel_id: str,
|
|
169
|
+
*,
|
|
170
|
+
strict: bool = True,
|
|
171
|
+
) -> InboundMessage:
|
|
172
|
+
"""Convert a Telnyx webhook POST body into an InboundMessage.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
payload: The Telnyx webhook POST body as a dictionary.
|
|
176
|
+
channel_id: The channel ID to associate with the message.
|
|
177
|
+
strict: If True (default), raises ValueError for non-inbound webhooks.
|
|
178
|
+
Set to False to skip validation (not recommended).
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
An InboundMessage ready for process_inbound().
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
ValueError: If strict=True and the webhook is not an inbound message.
|
|
185
|
+
|
|
186
|
+
Example:
|
|
187
|
+
Recommended: Use extract_sms_meta() for generic handling::
|
|
188
|
+
|
|
189
|
+
from roomkit import extract_sms_meta
|
|
190
|
+
|
|
191
|
+
@app.post("/webhooks/sms/{provider}")
|
|
192
|
+
async def sms_webhook(provider: str, payload: dict):
|
|
193
|
+
meta = extract_sms_meta(provider, payload)
|
|
194
|
+
if meta.is_inbound:
|
|
195
|
+
await kit.process_inbound(meta.to_inbound("sms"))
|
|
196
|
+
elif meta.is_status:
|
|
197
|
+
await kit.process_delivery_status(meta.to_status())
|
|
198
|
+
return {"ok": True}
|
|
199
|
+
"""
|
|
200
|
+
if strict and not _is_telnyx_inbound(payload):
|
|
201
|
+
event_type = payload.get("data", {}).get("event_type", "unknown")
|
|
202
|
+
direction = payload.get("data", {}).get("payload", {}).get("direction", "unknown")
|
|
203
|
+
raise ValueError(
|
|
204
|
+
f"Not an inbound message (event_type={event_type}, direction={direction}). "
|
|
205
|
+
f"Use extract_sms_meta() with meta.is_inbound to filter webhooks."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
data = payload["data"]["payload"]
|
|
209
|
+
body = data.get("text", "")
|
|
210
|
+
|
|
211
|
+
media: list[dict[str, str | None]] = []
|
|
212
|
+
for m in data.get("media", []):
|
|
213
|
+
if url := m.get("url"):
|
|
214
|
+
media.append(
|
|
215
|
+
{
|
|
216
|
+
"url": url,
|
|
217
|
+
"mime_type": m.get("content_type"),
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return InboundMessage(
|
|
222
|
+
channel_id=channel_id,
|
|
223
|
+
sender_id=data["from"]["phone_number"],
|
|
224
|
+
content=build_inbound_content(body, media),
|
|
225
|
+
external_id=data["id"],
|
|
226
|
+
idempotency_key=data["id"],
|
|
227
|
+
metadata={
|
|
228
|
+
"destination_number": data["to"][0]["phone_number"],
|
|
229
|
+
"received_at": data.get("received_at"),
|
|
230
|
+
},
|
|
231
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Twilio provider."""
|
|
2
|
+
|
|
3
|
+
from roomkit.providers.twilio.config import TwilioConfig
|
|
4
|
+
from roomkit.providers.twilio.rcs import (
|
|
5
|
+
TwilioRCSConfig,
|
|
6
|
+
TwilioRCSProvider,
|
|
7
|
+
parse_twilio_rcs_webhook,
|
|
8
|
+
)
|
|
9
|
+
from roomkit.providers.twilio.sms import TwilioSMSProvider, parse_twilio_webhook
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"TwilioConfig",
|
|
13
|
+
"TwilioSMSProvider",
|
|
14
|
+
"parse_twilio_webhook",
|
|
15
|
+
"TwilioRCSConfig",
|
|
16
|
+
"TwilioRCSProvider",
|
|
17
|
+
"parse_twilio_rcs_webhook",
|
|
18
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Twilio provider configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, SecretStr
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TwilioConfig(BaseModel):
|
|
9
|
+
"""Twilio SMS provider configuration."""
|
|
10
|
+
|
|
11
|
+
account_sid: str
|
|
12
|
+
auth_token: SecretStr
|
|
13
|
+
from_number: str
|
|
14
|
+
messaging_service_sid: str | None = None
|
|
15
|
+
timeout: float = 10.0
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def api_url(self) -> str:
|
|
19
|
+
return f"https://api.twilio.com/2010-04-01/Accounts/{self.account_sid}/Messages.json"
|