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,401 @@
1
+ """Normalized webhook metadata extraction for SMS providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ from roomkit.models.delivery import DeliveryStatus, InboundMessage
13
+ from roomkit.models.event import EventContent
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Shared content helpers
18
+ # ---------------------------------------------------------------------------
19
+
20
+
21
+ def extract_media_urls(content: EventContent) -> list[str]:
22
+ """Extract media URLs from MediaContent or CompositeContent.
23
+
24
+ Returns a list of URLs found in the content. Returns an empty list for
25
+ text-only content.
26
+ """
27
+ from roomkit.models.event import CompositeContent, MediaContent
28
+
29
+ if isinstance(content, MediaContent):
30
+ return [content.url]
31
+ if isinstance(content, CompositeContent):
32
+ urls: list[str] = []
33
+ for part in content.parts:
34
+ if isinstance(part, MediaContent):
35
+ urls.append(part.url)
36
+ return urls
37
+ return []
38
+
39
+
40
+ def extract_text_body(content: EventContent) -> str:
41
+ """Extract text from any content type.
42
+
43
+ - TextContent / RichContent / SystemContent / TemplateContent → body
44
+ - MediaContent → caption or empty string
45
+ - CompositeContent → joined text from all parts
46
+ """
47
+ from roomkit.models.event import CompositeContent, MediaContent
48
+
49
+ if isinstance(content, MediaContent):
50
+ return content.caption or ""
51
+ if isinstance(content, CompositeContent):
52
+ parts: list[str] = []
53
+ for part in content.parts:
54
+ text = extract_text_body(part)
55
+ if text:
56
+ parts.append(text)
57
+ return " ".join(parts)
58
+ # TextContent, RichContent, SystemContent, TemplateContent all have .body
59
+ if hasattr(content, "body") and content.body:
60
+ return str(content.body)
61
+ return ""
62
+
63
+
64
+ def build_inbound_content(
65
+ body: str,
66
+ media: list[dict[str, str | None]],
67
+ ) -> EventContent:
68
+ """Build the appropriate EventContent from text + media list.
69
+
70
+ Args:
71
+ body: Message text (may be empty).
72
+ media: List of dicts with ``url`` and optional ``mime_type`` keys.
73
+
74
+ Returns:
75
+ TextContent, MediaContent, or CompositeContent as appropriate.
76
+ """
77
+ from roomkit.models.event import (
78
+ CompositeContent,
79
+ MediaContent,
80
+ TextContent,
81
+ )
82
+
83
+ media_parts = [
84
+ MediaContent(
85
+ url=str(m["url"]),
86
+ mime_type=m.get("mime_type") or "application/octet-stream",
87
+ )
88
+ for m in media
89
+ if m.get("url")
90
+ ]
91
+
92
+ if not media_parts:
93
+ return TextContent(body=body)
94
+
95
+ if len(media_parts) == 1 and not body:
96
+ return media_parts[0]
97
+
98
+ if len(media_parts) == 1 and body:
99
+ # Single media with text → MediaContent with caption
100
+ mc = media_parts[0]
101
+ return MediaContent(url=mc.url, mime_type=mc.mime_type, caption=body)
102
+
103
+ # Multiple media, optionally with text
104
+ parts: list[TextContent | MediaContent] = []
105
+ if body:
106
+ parts.append(TextContent(body=body))
107
+ parts.extend(media_parts)
108
+ return CompositeContent(parts=parts) # type: ignore[arg-type]
109
+
110
+
111
+ @dataclass
112
+ class WebhookMeta:
113
+ """Normalized metadata extracted from any SMS provider webhook.
114
+
115
+ Attributes:
116
+ provider: Provider name (e.g., "telnyx", "twilio").
117
+ sender: Phone number that sent the message.
118
+ recipient: Phone number that received the message.
119
+ body: Message text content.
120
+ external_id: Provider's unique message identifier.
121
+ timestamp: When the message was received (if available).
122
+ raw: Original webhook payload for debugging.
123
+ media_urls: List of media attachments with url and mime_type.
124
+ direction: Message direction ("inbound" or "outbound").
125
+ event_type: Webhook event type (e.g., "message.received").
126
+ """
127
+
128
+ provider: str
129
+ sender: str
130
+ recipient: str
131
+ body: str
132
+ external_id: str | None
133
+ timestamp: datetime | None
134
+ raw: dict[str, Any]
135
+ media_urls: list[dict[str, str | None]] = field(default_factory=list)
136
+ direction: str | None = None
137
+ event_type: str | None = None
138
+
139
+ @property
140
+ def is_inbound(self) -> bool:
141
+ """Check if this webhook represents an inbound message.
142
+
143
+ Returns True if direction is "inbound" or event_type indicates
144
+ a received message. Returns True by default if direction/event_type
145
+ are not available (backwards compatibility).
146
+ """
147
+ # Explicit outbound check
148
+ if self.direction == "outbound":
149
+ return False
150
+
151
+ # Explicit inbound check
152
+ if self.direction == "inbound":
153
+ return True
154
+
155
+ # Event type checks for providers that use event_type
156
+ if self.event_type:
157
+ # Telnyx event types
158
+ if self.event_type in ("message.sent", "message.finalized"):
159
+ return False
160
+ if self.event_type == "message.received":
161
+ return True
162
+
163
+ # Default to True for backwards compatibility
164
+ # (older payloads may not have direction/event_type)
165
+ return True
166
+
167
+ @property
168
+ def is_status(self) -> bool:
169
+ """Check if this webhook represents a delivery status update."""
170
+ if self.direction != "outbound":
171
+ return False
172
+ status_events = {
173
+ "message.sent",
174
+ "message.delivered",
175
+ "message.failed",
176
+ "message.finalized",
177
+ }
178
+ return self.event_type in status_events
179
+
180
+ def to_status(self) -> DeliveryStatus:
181
+ """Convert to DeliveryStatus for delivery tracking."""
182
+ from roomkit.models.delivery import DeliveryStatus
183
+
184
+ status = (self.event_type or "unknown").replace("message.", "")
185
+ errors = self.raw.get("data", {}).get("payload", {}).get("errors", [])
186
+ error_code = str(errors[0].get("code", "")) if errors else None
187
+ error_message = str(errors[0].get("detail", "")) if errors else None
188
+
189
+ return DeliveryStatus(
190
+ provider=self.provider,
191
+ message_id=self.external_id or "",
192
+ status=status,
193
+ recipient=self.recipient,
194
+ sender=self.sender,
195
+ error_code=error_code,
196
+ error_message=error_message,
197
+ timestamp=self.timestamp.isoformat() if self.timestamp else None,
198
+ raw=self.raw,
199
+ )
200
+
201
+ def to_inbound(self, channel_id: str) -> InboundMessage:
202
+ """Convert to InboundMessage for use with RoomKit.process_inbound().
203
+
204
+ Args:
205
+ channel_id: The channel ID to associate with the message.
206
+
207
+ Returns:
208
+ An InboundMessage ready for process_inbound().
209
+
210
+ Raises:
211
+ ValueError: If this is not an inbound message (e.g., outbound status webhook).
212
+
213
+ Example:
214
+ meta = extract_sms_meta("twilio", payload)
215
+ sender = normalize_phone(meta.sender)
216
+ inbound = meta.to_inbound(channel_id="sms-channel")
217
+ result = await kit.process_inbound(inbound)
218
+ """
219
+ if not self.is_inbound:
220
+ raise ValueError(
221
+ f"Cannot convert outbound webhook to InboundMessage "
222
+ f"(provider={self.provider}, direction={self.direction}, "
223
+ f"event_type={self.event_type}). "
224
+ f"This is likely a delivery status webhook, not an inbound message."
225
+ )
226
+
227
+ from roomkit.models.delivery import InboundMessage
228
+
229
+ return InboundMessage(
230
+ channel_id=channel_id,
231
+ sender_id=self.sender,
232
+ content=build_inbound_content(self.body, self.media_urls),
233
+ external_id=self.external_id,
234
+ idempotency_key=self.external_id,
235
+ metadata={
236
+ "provider": self.provider,
237
+ "recipient": self.recipient,
238
+ "timestamp": self.timestamp.isoformat() if self.timestamp else None,
239
+ },
240
+ )
241
+
242
+
243
+ def extract_voicemeup_meta(payload: dict[str, Any]) -> WebhookMeta:
244
+ """Extract normalized metadata from a VoiceMeUp webhook payload."""
245
+ timestamp = None
246
+ if ts := payload.get("datetime_transmission"):
247
+ with contextlib.suppress(ValueError, AttributeError):
248
+ timestamp = datetime.fromisoformat(ts.replace("Z", "+00:00"))
249
+
250
+ media_urls: list[dict[str, str | None]] = []
251
+ # VoiceMeUp uses "attachment" for URL and "attachment_mime_type" for type
252
+ attachment_url = payload.get("attachment") or payload.get("attachment_url")
253
+ if attachment_url:
254
+ media_urls.append(
255
+ {
256
+ "url": attachment_url,
257
+ "mime_type": payload.get("attachment_mime_type") or payload.get("attachment_type"),
258
+ }
259
+ )
260
+
261
+ return WebhookMeta(
262
+ provider="voicemeup",
263
+ sender=payload.get("source_number", ""),
264
+ recipient=payload.get("destination_number", ""),
265
+ body=payload.get("message", ""),
266
+ external_id=payload.get("sms_hash"),
267
+ timestamp=timestamp,
268
+ raw=payload,
269
+ media_urls=media_urls,
270
+ )
271
+
272
+
273
+ def extract_telnyx_meta(payload: dict[str, Any]) -> WebhookMeta:
274
+ """Extract normalized metadata from a Telnyx webhook payload.
275
+
276
+ Note: This extracts metadata from ALL Telnyx webhooks, including outbound
277
+ status updates. Use ``meta.is_inbound`` to check if it's an inbound message
278
+ before processing.
279
+ """
280
+ event_data = payload.get("data", {})
281
+ data = event_data.get("payload", {})
282
+
283
+ timestamp = None
284
+ if ts := data.get("received_at"):
285
+ with contextlib.suppress(ValueError, AttributeError):
286
+ timestamp = datetime.fromisoformat(ts.replace("Z", "+00:00"))
287
+
288
+ from_data = data.get("from", {})
289
+ to_list = data.get("to", [])
290
+
291
+ media_urls: list[dict[str, str | None]] = []
292
+ for m in data.get("media", []):
293
+ if url := m.get("url"):
294
+ media_urls.append(
295
+ {
296
+ "url": url,
297
+ "mime_type": m.get("content_type"),
298
+ }
299
+ )
300
+
301
+ return WebhookMeta(
302
+ provider="telnyx",
303
+ sender=from_data.get("phone_number", ""),
304
+ recipient=to_list[0].get("phone_number", "") if to_list else "",
305
+ body=data.get("text", ""),
306
+ external_id=data.get("id"),
307
+ timestamp=timestamp,
308
+ raw=payload,
309
+ media_urls=media_urls,
310
+ direction=data.get("direction"),
311
+ event_type=event_data.get("event_type"),
312
+ )
313
+
314
+
315
+ def extract_twilio_meta(payload: dict[str, Any]) -> WebhookMeta:
316
+ """Extract normalized metadata from a Twilio webhook payload.
317
+
318
+ Note: Twilio sends form-encoded data. Convert to dict first:
319
+ payload = dict(await request.form())
320
+ """
321
+ media_urls: list[dict[str, str | None]] = []
322
+ num_media = int(payload.get("NumMedia", "0"))
323
+ for i in range(num_media):
324
+ url = payload.get(f"MediaUrl{i}")
325
+ if url:
326
+ media_urls.append(
327
+ {
328
+ "url": url,
329
+ "mime_type": payload.get(f"MediaContentType{i}"),
330
+ }
331
+ )
332
+
333
+ return WebhookMeta(
334
+ provider="twilio",
335
+ sender=payload.get("From", ""),
336
+ recipient=payload.get("To", ""),
337
+ body=payload.get("Body", ""),
338
+ external_id=payload.get("MessageSid"),
339
+ timestamp=None, # Twilio doesn't include timestamp in webhook
340
+ raw=payload,
341
+ media_urls=media_urls,
342
+ )
343
+
344
+
345
+ def extract_sinch_meta(payload: dict[str, Any]) -> WebhookMeta:
346
+ """Extract normalized metadata from a Sinch webhook payload."""
347
+ timestamp = None
348
+ if ts := payload.get("received_at"):
349
+ with contextlib.suppress(ValueError, AttributeError):
350
+ timestamp = datetime.fromisoformat(ts.replace("Z", "+00:00"))
351
+
352
+ media_urls: list[dict[str, str | None]] = []
353
+ for m in payload.get("media", []):
354
+ if url := m.get("url"):
355
+ media_urls.append(
356
+ {
357
+ "url": url,
358
+ "mime_type": m.get("mimeType"),
359
+ }
360
+ )
361
+
362
+ return WebhookMeta(
363
+ provider="sinch",
364
+ sender=payload.get("from", ""),
365
+ recipient=payload.get("to", ""),
366
+ body=payload.get("body", ""),
367
+ external_id=payload.get("id"),
368
+ timestamp=timestamp,
369
+ raw=payload,
370
+ media_urls=media_urls,
371
+ )
372
+
373
+
374
+ _ExtractorFn = Callable[[dict[str, Any]], WebhookMeta]
375
+
376
+ _EXTRACTORS: dict[str, _ExtractorFn] = {
377
+ "voicemeup": extract_voicemeup_meta,
378
+ "telnyx": extract_telnyx_meta,
379
+ "twilio": extract_twilio_meta,
380
+ "sinch": extract_sinch_meta,
381
+ }
382
+
383
+
384
+ def extract_sms_meta(provider: str, payload: dict[str, Any]) -> WebhookMeta:
385
+ """Extract normalized metadata from any supported SMS provider webhook.
386
+
387
+ Args:
388
+ provider: Provider name (e.g., "voicemeup", "telnyx").
389
+ payload: Raw webhook payload dictionary.
390
+
391
+ Returns:
392
+ Normalized WebhookMeta with provider-agnostic fields.
393
+
394
+ Raises:
395
+ ValueError: If the provider is not supported.
396
+ """
397
+ extractor = _EXTRACTORS.get(provider.lower())
398
+ if extractor is None:
399
+ supported = ", ".join(sorted(_EXTRACTORS.keys()))
400
+ raise ValueError(f"Unknown SMS provider: {provider}. Supported: {supported}")
401
+ return extractor(payload)
@@ -0,0 +1,24 @@
1
+ """Mock SMS provider for testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uuid import uuid4
6
+
7
+ from roomkit.models.delivery import ProviderResult
8
+ from roomkit.models.event import RoomEvent
9
+ from roomkit.providers.sms.base import SMSProvider
10
+
11
+
12
+ class MockSMSProvider(SMSProvider):
13
+ """Records sent messages for verification in tests."""
14
+
15
+ def __init__(self) -> None:
16
+ self.sent: list[dict[str, str | RoomEvent]] = []
17
+
18
+ @property
19
+ def from_number(self) -> str:
20
+ return "+15550001234"
21
+
22
+ async def send(self, event: RoomEvent, to: str, from_: str | None = None) -> ProviderResult:
23
+ self.sent.append({"event": event, "to": to, "from": from_ or ""})
24
+ return ProviderResult(success=True, provider_message_id=uuid4().hex)
@@ -0,0 +1,77 @@
1
+ """Phone number normalization utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def normalize_phone(number: str, default_region: str = "US") -> str:
7
+ """Normalize a phone number to E.164 format.
8
+
9
+ Args:
10
+ number: Phone number in any common format.
11
+ default_region: ISO 3166-1 alpha-2 country code for numbers without
12
+ country code (default: "US").
13
+
14
+ Returns:
15
+ Phone number in E.164 format (e.g., "+14185551234").
16
+
17
+ Raises:
18
+ ImportError: If phonenumbers library is not installed.
19
+ ValueError: If the number cannot be parsed or is invalid.
20
+
21
+ Example:
22
+ >>> normalize_phone("418-555-1234", "CA")
23
+ '+14185551234'
24
+ >>> normalize_phone("+1 (418) 555-1234")
25
+ '+14185551234'
26
+ >>> normalize_phone("14185551234")
27
+ '+14185551234'
28
+ """
29
+ try:
30
+ import phonenumbers
31
+ except ImportError as exc:
32
+ raise ImportError(
33
+ "phonenumbers is required for phone normalization. "
34
+ "Install it with: pip install roomkit[phonenumbers]"
35
+ ) from exc
36
+
37
+ # Handle numbers that start with country code but no +
38
+ cleaned = number.strip()
39
+ if cleaned and cleaned[0].isdigit() and len(cleaned) >= 10:
40
+ # Try parsing with + prefix first
41
+ try:
42
+ parsed = phonenumbers.parse(f"+{cleaned}", None)
43
+ if phonenumbers.is_valid_number(parsed):
44
+ return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
45
+ except phonenumbers.NumberParseException:
46
+ pass
47
+
48
+ try:
49
+ parsed = phonenumbers.parse(cleaned, default_region)
50
+ except phonenumbers.NumberParseException as exc:
51
+ raise ValueError(f"Cannot parse phone number: {number}") from exc
52
+
53
+ if not phonenumbers.is_valid_number(parsed):
54
+ raise ValueError(f"Invalid phone number: {number}")
55
+
56
+ return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
57
+
58
+
59
+ def is_valid_phone(number: str, default_region: str = "US") -> bool:
60
+ """Check if a phone number is valid.
61
+
62
+ Args:
63
+ number: Phone number in any common format.
64
+ default_region: ISO 3166-1 alpha-2 country code for numbers without
65
+ country code (default: "US").
66
+
67
+ Returns:
68
+ True if the number is valid, False otherwise.
69
+
70
+ Note:
71
+ Returns False if phonenumbers library is not installed.
72
+ """
73
+ try:
74
+ normalize_phone(number, default_region)
75
+ return True
76
+ except (ImportError, ValueError):
77
+ return False
@@ -0,0 +1,21 @@
1
+ """Telnyx provider."""
2
+
3
+ from roomkit.providers.telnyx.config import TelnyxConfig
4
+ from roomkit.providers.telnyx.rcs import (
5
+ TelnyxRCSConfig,
6
+ TelnyxRCSProvider,
7
+ parse_telnyx_rcs_webhook,
8
+ )
9
+ from roomkit.providers.telnyx.sms import (
10
+ TelnyxSMSProvider,
11
+ parse_telnyx_webhook,
12
+ )
13
+
14
+ __all__ = [
15
+ "TelnyxConfig",
16
+ "TelnyxRCSConfig",
17
+ "TelnyxRCSProvider",
18
+ "TelnyxSMSProvider",
19
+ "parse_telnyx_rcs_webhook",
20
+ "parse_telnyx_webhook",
21
+ ]
@@ -0,0 +1,14 @@
1
+ """Telnyx provider configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, SecretStr
6
+
7
+
8
+ class TelnyxConfig(BaseModel):
9
+ """Telnyx SMS provider configuration."""
10
+
11
+ api_key: SecretStr
12
+ from_number: str
13
+ messaging_profile_id: str | None = None
14
+ timeout: float = 10.0