Coverage for integrations / channels / extensions / voice_adapter.py: 35.1%
416 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Voice Channel Adapter
4Implements voice/phone integration with Twilio and Vonage APIs.
5Based on SantaClaw extension patterns for voice communication.
7Features:
8- Twilio Voice API integration
9- Vonage (Nexmo) Voice API integration
10- Inbound/Outbound calls
11- IVR (Interactive Voice Response)
12- DTMF input handling
13- Text-to-Speech (TTS)
14- Speech-to-Text (STT)
15- Call recording
16- Call transfers
17- Conference calls
18- Webhook handling
19- Real-time transcription
20"""
22from __future__ import annotations
24import asyncio
25import logging
26import os
27import json
28import hmac
29import hashlib
30import base64
31from typing import Optional, List, Dict, Any, Callable, Union
32from datetime import datetime
33from dataclasses import dataclass, field
34from enum import Enum
35from urllib.parse import urlencode
37try:
38 import aiohttp
39 HAS_HTTP = True
40except ImportError:
41 HAS_HTTP = False
43from ..base import (
44 ChannelAdapter,
45 ChannelConfig,
46 ChannelStatus,
47 Message,
48 MessageType,
49 MediaAttachment,
50 SendResult,
51 ChannelConnectionError,
52 ChannelSendError,
53 ChannelRateLimitError,
54)
56logger = logging.getLogger(__name__)
59class VoiceProvider(Enum):
60 """Voice service provider."""
61 TWILIO = "twilio"
62 VONAGE = "vonage"
65class CallStatus(Enum):
66 """Call status."""
67 QUEUED = "queued"
68 RINGING = "ringing"
69 IN_PROGRESS = "in-progress"
70 COMPLETED = "completed"
71 BUSY = "busy"
72 NO_ANSWER = "no-answer"
73 FAILED = "failed"
74 CANCELED = "canceled"
77class DTMFKey(Enum):
78 """DTMF key codes."""
79 KEY_0 = "0"
80 KEY_1 = "1"
81 KEY_2 = "2"
82 KEY_3 = "3"
83 KEY_4 = "4"
84 KEY_5 = "5"
85 KEY_6 = "6"
86 KEY_7 = "7"
87 KEY_8 = "8"
88 KEY_9 = "9"
89 KEY_STAR = "*"
90 KEY_HASH = "#"
93class TTSVoice(Enum):
94 """Text-to-Speech voice options."""
95 # Twilio voices
96 ALICE = "alice"
97 POLLY_JOANNA = "Polly.Joanna"
98 POLLY_MATTHEW = "Polly.Matthew"
99 POLLY_AMY = "Polly.Amy"
100 POLLY_BRIAN = "Polly.Brian"
101 # Vonage voices
102 VONAGE_EMMA = "emma"
103 VONAGE_AMY = "amy"
104 VONAGE_BRIAN = "brian"
105 VONAGE_JOEY = "joey"
108@dataclass
109class VoiceConfig(ChannelConfig):
110 """Voice-specific configuration."""
111 provider: VoiceProvider = VoiceProvider.TWILIO
112 # Twilio
113 twilio_account_sid: str = ""
114 twilio_auth_token: str = ""
115 twilio_phone_number: str = ""
116 # Vonage
117 vonage_api_key: str = ""
118 vonage_api_secret: str = ""
119 vonage_application_id: str = ""
120 vonage_private_key: str = ""
121 vonage_phone_number: str = ""
122 # Common
123 webhook_base_url: str = ""
124 default_voice: str = "alice"
125 default_language: str = "en-US"
126 enable_recording: bool = False
127 enable_transcription: bool = False
128 max_call_duration: int = 3600 # seconds
129 speech_timeout: int = 3 # seconds
132@dataclass
133class VoiceCall:
134 """Active voice call information."""
135 call_sid: str
136 from_number: str
137 to_number: str
138 status: CallStatus
139 direction: str # inbound, outbound
140 start_time: Optional[datetime] = None
141 duration: int = 0
142 recording_url: Optional[str] = None
143 transcription: Optional[str] = None
146@dataclass
147class IVRMenu:
148 """IVR menu configuration."""
149 name: str
150 prompt: str
151 options: Dict[str, Dict[str, Any]] = field(default_factory=dict)
152 timeout_action: Optional[Dict[str, Any]] = None
153 invalid_action: Optional[Dict[str, Any]] = None
154 max_attempts: int = 3
157class VoiceAdapter(ChannelAdapter):
158 """
159 Voice/Phone adapter with Twilio and Vonage support.
161 Usage:
162 # Twilio
163 config = VoiceConfig(
164 provider=VoiceProvider.TWILIO,
165 twilio_account_sid="your-sid",
166 twilio_auth_token="your-token",
167 twilio_phone_number="+1234567890",
168 webhook_base_url="https://your-server.com",
169 )
171 # Vonage
172 config = VoiceConfig(
173 provider=VoiceProvider.VONAGE,
174 vonage_api_key="your-key",
175 vonage_api_secret="your-secret",
176 vonage_phone_number="+1234567890",
177 )
179 adapter = VoiceAdapter(config)
180 adapter.on_message(my_handler)
181 await adapter.start()
182 """
184 def __init__(self, config: VoiceConfig):
185 if not HAS_HTTP:
186 raise ImportError(
187 "aiohttp not installed. "
188 "Install with: pip install aiohttp"
189 )
191 super().__init__(config)
192 self.voice_config: VoiceConfig = config
193 self._session: Optional[aiohttp.ClientSession] = None
194 self._active_calls: Dict[str, VoiceCall] = {}
195 self._ivr_menus: Dict[str, IVRMenu] = {}
196 self._dtmf_handlers: Dict[str, Callable] = {}
197 self._speech_handlers: List[Callable] = []
198 self._call_start_handlers: List[Callable] = []
199 self._call_end_handlers: List[Callable] = []
201 @property
202 def name(self) -> str:
203 return "voice"
205 async def connect(self) -> bool:
206 """Initialize voice adapter."""
207 try:
208 self._session = aiohttp.ClientSession()
210 # Validate provider credentials
211 if self.voice_config.provider == VoiceProvider.TWILIO:
212 if not self.voice_config.twilio_account_sid or not self.voice_config.twilio_auth_token:
213 logger.error("Twilio credentials required")
214 return False
216 # Verify credentials
217 if not await self._verify_twilio():
218 return False
220 elif self.voice_config.provider == VoiceProvider.VONAGE:
221 if not self.voice_config.vonage_api_key or not self.voice_config.vonage_api_secret:
222 logger.error("Vonage credentials required")
223 return False
225 # Verify credentials
226 if not await self._verify_vonage():
227 return False
229 self.status = ChannelStatus.CONNECTED
230 logger.info(f"Voice adapter connected ({self.voice_config.provider.value})")
231 return True
233 except Exception as e:
234 logger.error(f"Failed to connect voice adapter: {e}")
235 self.status = ChannelStatus.ERROR
236 return False
238 async def disconnect(self) -> None:
239 """Disconnect voice adapter."""
240 if self._session:
241 await self._session.close()
242 self._session = None
244 self._active_calls.clear()
245 self.status = ChannelStatus.DISCONNECTED
247 async def _verify_twilio(self) -> bool:
248 """Verify Twilio credentials."""
249 if not self._session:
250 return False
252 try:
253 url = f"https://api.twilio.com/2010-04-01/Accounts/{self.voice_config.twilio_account_sid}.json"
254 auth = aiohttp.BasicAuth(
255 self.voice_config.twilio_account_sid,
256 self.voice_config.twilio_auth_token,
257 )
259 async with self._session.get(url, auth=auth) as resp:
260 if resp.status == 200:
261 data = await resp.json()
262 logger.info(f"Twilio account: {data.get('friendly_name')}")
263 return True
264 else:
265 logger.error(f"Twilio verification failed: {resp.status}")
266 return False
268 except Exception as e:
269 logger.error(f"Twilio verification error: {e}")
270 return False
272 async def _verify_vonage(self) -> bool:
273 """Verify Vonage credentials."""
274 if not self._session:
275 return False
277 try:
278 url = "https://api.nexmo.com/account/get-balance"
279 params = {
280 "api_key": self.voice_config.vonage_api_key,
281 "api_secret": self.voice_config.vonage_api_secret,
282 }
284 async with self._session.get(url, params=params) as resp:
285 if resp.status == 200:
286 data = await resp.json()
287 logger.info(f"Vonage balance: {data.get('value')}")
288 return True
289 else:
290 logger.error(f"Vonage verification failed: {resp.status}")
291 return False
293 except Exception as e:
294 logger.error(f"Vonage verification error: {e}")
295 return False
297 def validate_twilio_signature(
298 self,
299 url: str,
300 params: Dict[str, str],
301 signature: str,
302 ) -> bool:
303 """Validate Twilio webhook signature."""
304 if not self.voice_config.twilio_auth_token:
305 return False
307 try:
308 # Build validation string
309 s = url
310 if params:
311 s += "".join(f"{k}{v}" for k, v in sorted(params.items()))
313 expected = base64.b64encode(
314 hmac.new(
315 self.voice_config.twilio_auth_token.encode(),
316 s.encode(),
317 hashlib.sha1,
318 ).digest()
319 ).decode()
321 return hmac.compare_digest(signature, expected)
323 except Exception:
324 return False
326 async def handle_webhook(self, body: Dict[str, Any], event_type: str = "") -> Dict[str, Any]:
327 """
328 Handle incoming webhook from Twilio/Vonage.
329 Returns TwiML/NCCO response.
330 """
331 if self.voice_config.provider == VoiceProvider.TWILIO:
332 return await self._handle_twilio_webhook(body, event_type)
333 else:
334 return await self._handle_vonage_webhook(body, event_type)
336 async def _handle_twilio_webhook(
337 self,
338 body: Dict[str, Any],
339 event_type: str,
340 ) -> Dict[str, Any]:
341 """Handle Twilio webhook."""
342 call_sid = body.get("CallSid", "")
343 call_status = body.get("CallStatus", "")
345 if event_type == "voice" or "CallSid" in body:
346 # Incoming call or call status
347 return await self._handle_twilio_call(body)
349 elif event_type == "gather" or "Digits" in body:
350 # DTMF input
351 return await self._handle_twilio_gather(body)
353 elif event_type == "speech" or "SpeechResult" in body:
354 # Speech input
355 return await self._handle_twilio_speech(body)
357 elif event_type == "recording":
358 # Recording complete
359 await self._handle_twilio_recording(body)
361 elif event_type == "transcription":
362 # Transcription complete
363 await self._handle_twilio_transcription(body)
365 return {"twiml": "<Response></Response>"}
367 async def _handle_twilio_call(self, body: Dict[str, Any]) -> Dict[str, Any]:
368 """Handle Twilio incoming call."""
369 call_sid = body.get("CallSid", "")
370 from_number = body.get("From", "")
371 to_number = body.get("To", "")
372 direction = body.get("Direction", "inbound")
373 status = body.get("CallStatus", "ringing")
375 # Create/update call record
376 call = VoiceCall(
377 call_sid=call_sid,
378 from_number=from_number,
379 to_number=to_number,
380 status=CallStatus(status),
381 direction=direction,
382 start_time=datetime.now(),
383 )
384 self._active_calls[call_sid] = call
386 # Notify handlers
387 for handler in self._call_start_handlers:
388 try:
389 result = handler(call)
390 if asyncio.iscoroutine(result):
391 await result
392 except Exception as e:
393 logger.error(f"Call start handler error: {e}")
395 # Create message for incoming call
396 message = Message(
397 id=call_sid,
398 channel=self.name,
399 sender_id=from_number,
400 sender_name=from_number,
401 chat_id=f"call:{call_sid}",
402 text="[Incoming Call]",
403 timestamp=datetime.now(),
404 is_group=False,
405 raw={
406 "call_sid": call_sid,
407 "direction": direction,
408 "status": status,
409 },
410 )
412 await self._dispatch_message(message)
414 # Return default greeting TwiML
415 twiml = self._build_default_greeting_twiml()
416 return {"twiml": twiml}
418 async def _handle_twilio_gather(self, body: Dict[str, Any]) -> Dict[str, Any]:
419 """Handle Twilio DTMF gather."""
420 call_sid = body.get("CallSid", "")
421 digits = body.get("Digits", "")
423 # Create message for DTMF input
424 message = Message(
425 id=f"{call_sid}_{digits}",
426 channel=self.name,
427 sender_id=body.get("From", ""),
428 chat_id=f"call:{call_sid}",
429 text=f"[DTMF:{digits}]",
430 timestamp=datetime.now(),
431 raw={
432 "call_sid": call_sid,
433 "digits": digits,
434 "input_type": "dtmf",
435 },
436 )
438 await self._dispatch_message(message)
440 # Check for registered handler
441 if digits in self._dtmf_handlers:
442 handler = self._dtmf_handlers[digits]
443 try:
444 result = await handler(call_sid, digits)
445 if isinstance(result, str):
446 return {"twiml": result}
447 except Exception as e:
448 logger.error(f"DTMF handler error: {e}")
450 return {"twiml": "<Response></Response>"}
452 async def _handle_twilio_speech(self, body: Dict[str, Any]) -> Dict[str, Any]:
453 """Handle Twilio speech recognition result."""
454 call_sid = body.get("CallSid", "")
455 speech_result = body.get("SpeechResult", "")
456 confidence = body.get("Confidence", 0)
458 # Create message for speech input
459 message = Message(
460 id=f"{call_sid}_speech",
461 channel=self.name,
462 sender_id=body.get("From", ""),
463 chat_id=f"call:{call_sid}",
464 text=speech_result,
465 timestamp=datetime.now(),
466 raw={
467 "call_sid": call_sid,
468 "speech_result": speech_result,
469 "confidence": confidence,
470 "input_type": "speech",
471 },
472 )
474 await self._dispatch_message(message)
476 # Notify speech handlers
477 for handler in self._speech_handlers:
478 try:
479 result = handler(call_sid, speech_result, confidence)
480 if asyncio.iscoroutine(result):
481 result = await result
482 if isinstance(result, str):
483 return {"twiml": result}
484 except Exception as e:
485 logger.error(f"Speech handler error: {e}")
487 return {"twiml": "<Response></Response>"}
489 async def _handle_twilio_recording(self, body: Dict[str, Any]) -> None:
490 """Handle Twilio recording complete."""
491 call_sid = body.get("CallSid", "")
492 recording_url = body.get("RecordingUrl", "")
494 if call_sid in self._active_calls:
495 self._active_calls[call_sid].recording_url = recording_url
497 logger.info(f"Recording complete for {call_sid}: {recording_url}")
499 async def _handle_twilio_transcription(self, body: Dict[str, Any]) -> None:
500 """Handle Twilio transcription complete."""
501 call_sid = body.get("CallSid", "")
502 transcription = body.get("TranscriptionText", "")
504 if call_sid in self._active_calls:
505 self._active_calls[call_sid].transcription = transcription
507 logger.info(f"Transcription for {call_sid}: {transcription}")
509 async def _handle_vonage_webhook(
510 self,
511 body: Dict[str, Any],
512 event_type: str,
513 ) -> Dict[str, Any]:
514 """Handle Vonage webhook."""
515 conversation_uuid = body.get("conversation_uuid", "")
517 if body.get("status") == "started":
518 return await self._handle_vonage_call_started(body)
519 elif body.get("dtmf"):
520 return await self._handle_vonage_dtmf(body)
521 elif body.get("speech"):
522 return await self._handle_vonage_speech(body)
524 return {"ncco": []}
526 async def _handle_vonage_call_started(self, body: Dict[str, Any]) -> Dict[str, Any]:
527 """Handle Vonage call started."""
528 uuid = body.get("uuid", "")
529 from_number = body.get("from", "")
530 to_number = body.get("to", "")
532 call = VoiceCall(
533 call_sid=uuid,
534 from_number=from_number,
535 to_number=to_number,
536 status=CallStatus.IN_PROGRESS,
537 direction=body.get("direction", "inbound"),
538 start_time=datetime.now(),
539 )
540 self._active_calls[uuid] = call
542 # Create message
543 message = Message(
544 id=uuid,
545 channel=self.name,
546 sender_id=from_number,
547 chat_id=f"call:{uuid}",
548 text="[Incoming Call]",
549 timestamp=datetime.now(),
550 raw={"uuid": uuid},
551 )
553 await self._dispatch_message(message)
555 # Return default NCCO
556 ncco = self._build_default_greeting_ncco()
557 return {"ncco": ncco}
559 async def _handle_vonage_dtmf(self, body: Dict[str, Any]) -> Dict[str, Any]:
560 """Handle Vonage DTMF input."""
561 uuid = body.get("uuid", "")
562 dtmf = body.get("dtmf", {})
563 digits = dtmf.get("digits", "")
565 message = Message(
566 id=f"{uuid}_{digits}",
567 channel=self.name,
568 sender_id=body.get("from", ""),
569 chat_id=f"call:{uuid}",
570 text=f"[DTMF:{digits}]",
571 timestamp=datetime.now(),
572 raw={"uuid": uuid, "digits": digits},
573 )
575 await self._dispatch_message(message)
576 return {"ncco": []}
578 async def _handle_vonage_speech(self, body: Dict[str, Any]) -> Dict[str, Any]:
579 """Handle Vonage speech input."""
580 uuid = body.get("uuid", "")
581 speech = body.get("speech", {})
582 results = speech.get("results", [])
584 if results:
585 text = results[0].get("text", "")
586 confidence = results[0].get("confidence", 0)
588 message = Message(
589 id=f"{uuid}_speech",
590 channel=self.name,
591 sender_id=body.get("from", ""),
592 chat_id=f"call:{uuid}",
593 text=text,
594 timestamp=datetime.now(),
595 raw={"uuid": uuid, "confidence": confidence},
596 )
598 await self._dispatch_message(message)
600 return {"ncco": []}
602 def _build_default_greeting_twiml(self) -> str:
603 """Build default TwiML greeting."""
604 voice = self.voice_config.default_voice
605 language = self.voice_config.default_language
607 return f"""<?xml version="1.0" encoding="UTF-8"?>
608<Response>
609 <Say voice="{voice}" language="{language}">
610 Hello, thank you for calling. How can I help you today?
611 </Say>
612 <Gather input="speech dtmf" timeout="{self.voice_config.speech_timeout}" action="/voice/gather">
613 <Say voice="{voice}">Please speak or press a key.</Say>
614 </Gather>
615</Response>"""
617 def _build_default_greeting_ncco(self) -> List[Dict[str, Any]]:
618 """Build default Vonage NCCO greeting."""
619 return [
620 {
621 "action": "talk",
622 "text": "Hello, thank you for calling. How can I help you today?",
623 "voiceName": self.voice_config.default_voice,
624 },
625 {
626 "action": "input",
627 "type": ["speech", "dtmf"],
628 "speech": {
629 "language": self.voice_config.default_language,
630 },
631 },
632 ]
634 async def send_message(
635 self,
636 chat_id: str,
637 text: str,
638 reply_to: Optional[str] = None,
639 media: Optional[List[MediaAttachment]] = None,
640 buttons: Optional[List[Dict]] = None,
641 ) -> SendResult:
642 """Send TTS message to active call."""
643 if not chat_id.startswith("call:"):
644 return SendResult(success=False, error="Invalid chat_id for voice")
646 call_sid = chat_id.replace("call:", "")
648 if call_sid not in self._active_calls:
649 return SendResult(success=False, error="Call not found")
651 # In practice, TTS is handled via TwiML/NCCO response
652 # This would be used for out-of-band updates
653 logger.info(f"TTS message for {call_sid}: {text}")
654 return SendResult(success=True)
656 async def edit_message(
657 self,
658 chat_id: str,
659 message_id: str,
660 text: str,
661 buttons: Optional[List[Dict]] = None,
662 ) -> SendResult:
663 """Not applicable for voice."""
664 return SendResult(success=False, error="Not supported for voice")
666 async def delete_message(self, chat_id: str, message_id: str) -> bool:
667 """Not applicable for voice."""
668 return False
670 async def send_typing(self, chat_id: str) -> None:
671 """Not applicable for voice."""
672 pass
674 async def get_chat_info(self, chat_id: str) -> Optional[Dict[str, Any]]:
675 """Get call information."""
676 if not chat_id.startswith("call:"):
677 return None
679 call_sid = chat_id.replace("call:", "")
681 if call_sid in self._active_calls:
682 call = self._active_calls[call_sid]
683 return {
684 "call_sid": call.call_sid,
685 "from": call.from_number,
686 "to": call.to_number,
687 "status": call.status.value,
688 "direction": call.direction,
689 "duration": call.duration,
690 }
692 return None
694 # Voice-specific methods
696 async def make_call(
697 self,
698 to_number: str,
699 twiml_url: Optional[str] = None,
700 twiml: Optional[str] = None,
701 ) -> Optional[str]:
702 """Make an outbound call (Twilio)."""
703 if self.voice_config.provider != VoiceProvider.TWILIO:
704 logger.error("make_call only supported for Twilio")
705 return None
707 if not self._session:
708 return None
710 try:
711 url = f"https://api.twilio.com/2010-04-01/Accounts/{self.voice_config.twilio_account_sid}/Calls.json"
712 auth = aiohttp.BasicAuth(
713 self.voice_config.twilio_account_sid,
714 self.voice_config.twilio_auth_token,
715 )
717 data = {
718 "From": self.voice_config.twilio_phone_number,
719 "To": to_number,
720 }
722 if twiml_url:
723 data["Url"] = twiml_url
724 elif twiml:
725 data["Twiml"] = twiml
726 else:
727 data["Twiml"] = self._build_default_greeting_twiml()
729 async with self._session.post(url, auth=auth, data=data) as resp:
730 if resp.status == 201:
731 result = await resp.json()
732 call_sid = result.get("sid")
734 call = VoiceCall(
735 call_sid=call_sid,
736 from_number=self.voice_config.twilio_phone_number,
737 to_number=to_number,
738 status=CallStatus.QUEUED,
739 direction="outbound",
740 )
741 self._active_calls[call_sid] = call
743 return call_sid
744 else:
745 error = await resp.text()
746 logger.error(f"Failed to make call: {error}")
747 return None
749 except Exception as e:
750 logger.error(f"Make call error: {e}")
751 return None
753 async def hangup_call(self, call_sid: str) -> bool:
754 """Hang up an active call."""
755 if self.voice_config.provider == VoiceProvider.TWILIO:
756 return await self._hangup_twilio(call_sid)
757 else:
758 return await self._hangup_vonage(call_sid)
760 async def _hangup_twilio(self, call_sid: str) -> bool:
761 """Hang up Twilio call."""
762 if not self._session:
763 return False
765 try:
766 url = f"https://api.twilio.com/2010-04-01/Accounts/{self.voice_config.twilio_account_sid}/Calls/{call_sid}.json"
767 auth = aiohttp.BasicAuth(
768 self.voice_config.twilio_account_sid,
769 self.voice_config.twilio_auth_token,
770 )
772 data = {"Status": "completed"}
774 async with self._session.post(url, auth=auth, data=data) as resp:
775 if resp.status == 200:
776 if call_sid in self._active_calls:
777 self._active_calls[call_sid].status = CallStatus.COMPLETED
778 return True
780 except Exception as e:
781 logger.error(f"Hangup error: {e}")
783 return False
785 async def _hangup_vonage(self, uuid: str) -> bool:
786 """Hang up Vonage call."""
787 if not self._session:
788 return False
790 try:
791 url = f"https://api.nexmo.com/v1/calls/{uuid}"
792 headers = {
793 "Authorization": f"Bearer {self._get_vonage_jwt()}",
794 "Content-Type": "application/json",
795 }
797 data = {"action": "hangup"}
799 async with self._session.put(url, headers=headers, json=data) as resp:
800 return resp.status == 204
802 except Exception as e:
803 logger.error(f"Vonage hangup error: {e}")
805 return False
807 def _get_vonage_jwt(self) -> str:
808 """Generate Vonage JWT token."""
809 # Simplified - in production use proper JWT library
810 # This is a placeholder
811 return "vonage_jwt_token"
813 def register_dtmf_handler(
814 self,
815 key: str,
816 handler: Callable[[str, str], Any],
817 ) -> None:
818 """Register handler for DTMF key press."""
819 self._dtmf_handlers[key] = handler
821 def on_speech(self, handler: Callable[[str, str, float], Any]) -> None:
822 """Register speech recognition handler."""
823 self._speech_handlers.append(handler)
825 def on_call_start(self, handler: Callable[[VoiceCall], Any]) -> None:
826 """Register call start handler."""
827 self._call_start_handlers.append(handler)
829 def on_call_end(self, handler: Callable[[VoiceCall], Any]) -> None:
830 """Register call end handler."""
831 self._call_end_handlers.append(handler)
833 def register_ivr_menu(self, menu: IVRMenu) -> None:
834 """Register an IVR menu."""
835 self._ivr_menus[menu.name] = menu
837 def build_twiml_say(
838 self,
839 text: str,
840 voice: Optional[str] = None,
841 language: Optional[str] = None,
842 ) -> str:
843 """Build TwiML Say element."""
844 voice = voice or self.voice_config.default_voice
845 language = language or self.voice_config.default_language
846 return f'<Say voice="{voice}" language="{language}">{text}</Say>'
848 def build_twiml_gather(
849 self,
850 prompt: str,
851 input_type: str = "dtmf speech",
852 action_url: str = "/voice/gather",
853 timeout: Optional[int] = None,
854 num_digits: Optional[int] = None,
855 ) -> str:
856 """Build TwiML Gather element."""
857 timeout = timeout or self.voice_config.speech_timeout
858 voice = self.voice_config.default_voice
860 gather_attrs = f'input="{input_type}" timeout="{timeout}" action="{action_url}"'
861 if num_digits:
862 gather_attrs += f' numDigits="{num_digits}"'
864 return f"""<Gather {gather_attrs}>
865 <Say voice="{voice}">{prompt}</Say>
866</Gather>"""
868 def build_twiml_record(
869 self,
870 action_url: str = "/voice/recording",
871 transcribe: bool = False,
872 max_length: int = 120,
873 ) -> str:
874 """Build TwiML Record element."""
875 transcribe_attr = 'transcribe="true" transcribeCallback="/voice/transcription"' if transcribe else ""
876 return f'<Record action="{action_url}" maxLength="{max_length}" {transcribe_attr}/>'
878 def build_twiml_dial(
879 self,
880 number: str,
881 caller_id: Optional[str] = None,
882 timeout: int = 30,
883 ) -> str:
884 """Build TwiML Dial element."""
885 caller_id = caller_id or self.voice_config.twilio_phone_number
886 return f'<Dial callerId="{caller_id}" timeout="{timeout}"><Number>{number}</Number></Dial>'
888 def build_ncco_talk(
889 self,
890 text: str,
891 voice: Optional[str] = None,
892 loop: int = 1,
893 ) -> Dict[str, Any]:
894 """Build Vonage NCCO talk action."""
895 return {
896 "action": "talk",
897 "text": text,
898 "voiceName": voice or self.voice_config.default_voice,
899 "loop": loop,
900 }
902 def build_ncco_input(
903 self,
904 event_url: str,
905 dtmf: bool = True,
906 speech: bool = True,
907 max_digits: Optional[int] = None,
908 ) -> Dict[str, Any]:
909 """Build Vonage NCCO input action."""
910 types = []
911 if dtmf:
912 types.append("dtmf")
913 if speech:
914 types.append("speech")
916 action = {
917 "action": "input",
918 "type": types,
919 "eventUrl": [event_url],
920 }
922 if dtmf and max_digits:
923 action["dtmf"] = {"maxDigits": max_digits}
925 if speech:
926 action["speech"] = {
927 "language": self.voice_config.default_language,
928 }
930 return action
932 def build_ncco_record(
933 self,
934 event_url: str,
935 format: str = "mp3",
936 ) -> Dict[str, Any]:
937 """Build Vonage NCCO record action."""
938 return {
939 "action": "record",
940 "format": format,
941 "eventUrl": [event_url],
942 }
945def create_voice_adapter(
946 provider: str = "twilio",
947 **kwargs
948) -> VoiceAdapter:
949 """
950 Factory function to create Voice adapter.
952 Args:
953 provider: Voice provider ("twilio" or "vonage")
954 **kwargs: Provider-specific configuration
956 For Twilio, set:
957 - twilio_account_sid or TWILIO_ACCOUNT_SID env var
958 - twilio_auth_token or TWILIO_AUTH_TOKEN env var
959 - twilio_phone_number or TWILIO_PHONE_NUMBER env var
961 For Vonage, set:
962 - vonage_api_key or VONAGE_API_KEY env var
963 - vonage_api_secret or VONAGE_API_SECRET env var
964 - vonage_phone_number or VONAGE_PHONE_NUMBER env var
966 Returns:
967 Configured VoiceAdapter
968 """
969 provider_enum = VoiceProvider(provider.lower())
971 if provider_enum == VoiceProvider.TWILIO:
972 account_sid = kwargs.pop("twilio_account_sid", None) or os.getenv("TWILIO_ACCOUNT_SID")
973 auth_token = kwargs.pop("twilio_auth_token", None) or os.getenv("TWILIO_AUTH_TOKEN")
974 phone_number = kwargs.pop("twilio_phone_number", None) or os.getenv("TWILIO_PHONE_NUMBER")
976 if not account_sid or not auth_token:
977 raise ValueError("Twilio account SID and auth token required")
979 config = VoiceConfig(
980 provider=provider_enum,
981 twilio_account_sid=account_sid,
982 twilio_auth_token=auth_token,
983 twilio_phone_number=phone_number or "",
984 **kwargs
985 )
987 elif provider_enum == VoiceProvider.VONAGE:
988 api_key = kwargs.pop("vonage_api_key", None) or os.getenv("VONAGE_API_KEY")
989 api_secret = kwargs.pop("vonage_api_secret", None) or os.getenv("VONAGE_API_SECRET")
990 phone_number = kwargs.pop("vonage_phone_number", None) or os.getenv("VONAGE_PHONE_NUMBER")
992 if not api_key or not api_secret:
993 raise ValueError("Vonage API key and secret required")
995 config = VoiceConfig(
996 provider=provider_enum,
997 vonage_api_key=api_key,
998 vonage_api_secret=api_secret,
999 vonage_phone_number=phone_number or "",
1000 **kwargs
1001 )
1003 else:
1004 raise ValueError(f"Unknown provider: {provider}")
1006 return VoiceAdapter(config)