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,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"