From 5e7d13ec610bc972149b8e16bb9007f0fe75c588 Mon Sep 17 00:00:00 2001 From: maxkahan Date: Thu, 11 Apr 2024 19:38:57 +0100 Subject: [PATCH] start adding voice api and ncco builder --- .../vonage_messages/models/base_message.py | 2 +- messages/src/vonage_messages/models/mms.py | 2 +- messages/src/vonage_messages/models/sms.py | 2 +- .../src/vonage_messages/models/whatsapp.py | 2 +- users/src/vonage_users/common.py | 2 +- verify/src/vonage_verify/requests.py | 2 +- verify_v2/src/vonage_verify_v2/requests.py | 2 +- voice/BUILD | 16 ++ voice/CHANGES.md | 5 + voice/README.md | 9 + voice/pyproject.toml | 29 +++ .../types => voice/src/vonage_voice}/BUILD | 0 voice/src/vonage_voice/__init__.py | 3 + voice/src/vonage_voice/errors.py | 9 + voice/src/vonage_voice/models/common.py | 9 + .../vonage_voice/models/connect_endpoints.py | 48 ++++ voice/src/vonage_voice/models/enums.py | 26 ++ voice/src/vonage_voice/models/input_types.py | 26 ++ voice/src/vonage_voice/models/ncco.py | 224 ++++++++++++++++++ voice/src/vonage_voice/models/requests.py | 79 ++++++ voice/src/vonage_voice/models/responses.py | 13 + voice/src/vonage_voice/voice.py | 30 +++ voice/tests/BUILD | 1 + voice/tests/_test_models.py | 138 +++++++++++ voice/tests/_test_verify_v2.py | 184 ++++++++++++++ voice/tests/data/check_code.json | 4 + voice/tests/data/check_code_400.json | 6 + voice/tests/data/check_code_410.json | 6 + .../data/trigger_next_workflow_error.json | 6 + voice/tests/data/verify_request.json | 4 + voice/tests/data/verify_request_error.json | 6 + vonage/CHANGES.md | 3 + vonage/pyproject.toml | 1 + vonage/src/vonage/__init__.py | 2 + vonage/src/vonage/vonage.py | 2 + vonage_utils/src/vonage_utils/__init__.py | 5 +- .../{types/phone_number.py => types.py} | 2 + .../src/vonage_utils/types/__init__.py | 0 38 files changed, 901 insertions(+), 9 deletions(-) create mode 100644 voice/BUILD create mode 100644 voice/CHANGES.md create mode 100644 voice/README.md create mode 100644 voice/pyproject.toml rename {vonage_utils/src/vonage_utils/types => voice/src/vonage_voice}/BUILD (100%) create mode 100644 voice/src/vonage_voice/__init__.py create mode 100644 voice/src/vonage_voice/errors.py create mode 100644 voice/src/vonage_voice/models/common.py create mode 100644 voice/src/vonage_voice/models/connect_endpoints.py create mode 100644 voice/src/vonage_voice/models/enums.py create mode 100644 voice/src/vonage_voice/models/input_types.py create mode 100644 voice/src/vonage_voice/models/ncco.py create mode 100644 voice/src/vonage_voice/models/requests.py create mode 100644 voice/src/vonage_voice/models/responses.py create mode 100644 voice/src/vonage_voice/voice.py create mode 100644 voice/tests/BUILD create mode 100644 voice/tests/_test_models.py create mode 100644 voice/tests/_test_verify_v2.py create mode 100644 voice/tests/data/check_code.json create mode 100644 voice/tests/data/check_code_400.json create mode 100644 voice/tests/data/check_code_410.json create mode 100644 voice/tests/data/trigger_next_workflow_error.json create mode 100644 voice/tests/data/verify_request.json create mode 100644 voice/tests/data/verify_request_error.json rename vonage_utils/src/vonage_utils/{types/phone_number.py => types.py} (50%) delete mode 100644 vonage_utils/src/vonage_utils/types/__init__.py diff --git a/messages/src/vonage_messages/models/base_message.py b/messages/src/vonage_messages/models/base_message.py index ba59d0a0..aa74e733 100644 --- a/messages/src/vonage_messages/models/base_message.py +++ b/messages/src/vonage_messages/models/base_message.py @@ -1,7 +1,7 @@ from typing import Optional from pydantic import BaseModel, Field -from vonage_utils.types.phone_number import PhoneNumber +from vonage_utils.types import PhoneNumber from .enums import WebhookVersion diff --git a/messages/src/vonage_messages/models/mms.py b/messages/src/vonage_messages/models/mms.py index dc252c42..5dcd1e8a 100644 --- a/messages/src/vonage_messages/models/mms.py +++ b/messages/src/vonage_messages/models/mms.py @@ -1,7 +1,7 @@ from typing import Optional, Union from pydantic import BaseModel, Field -from vonage_utils.types.phone_number import PhoneNumber +from vonage_utils.types import PhoneNumber from .base_message import BaseMessage from .enums import ChannelType, MessageType diff --git a/messages/src/vonage_messages/models/sms.py b/messages/src/vonage_messages/models/sms.py index 56e89901..cf6e5dbd 100644 --- a/messages/src/vonage_messages/models/sms.py +++ b/messages/src/vonage_messages/models/sms.py @@ -1,7 +1,7 @@ from typing import Optional, Union from pydantic import BaseModel, Field -from vonage_utils.types.phone_number import PhoneNumber +from vonage_utils.types import PhoneNumber from .base_message import BaseMessage from .enums import ChannelType, EncodingType, MessageType diff --git a/messages/src/vonage_messages/models/whatsapp.py b/messages/src/vonage_messages/models/whatsapp.py index 01c12fe5..c5ee18cc 100644 --- a/messages/src/vonage_messages/models/whatsapp.py +++ b/messages/src/vonage_messages/models/whatsapp.py @@ -1,7 +1,7 @@ from typing import List, Literal, Optional, Union from pydantic import BaseModel, ConfigDict, Field -from vonage_utils.types.phone_number import PhoneNumber +from vonage_utils.types import PhoneNumber from .base_message import BaseMessage from .enums import ChannelType, MessageType diff --git a/users/src/vonage_users/common.py b/users/src/vonage_users/common.py index a7b8dfd7..ab1118ca 100644 --- a/users/src/vonage_users/common.py +++ b/users/src/vonage_users/common.py @@ -1,7 +1,7 @@ from typing import List, Optional from pydantic import BaseModel, Field, model_validator -from vonage_utils.types.phone_number import PhoneNumber +from vonage_utils.types import PhoneNumber class Link(BaseModel): diff --git a/verify/src/vonage_verify/requests.py b/verify/src/vonage_verify/requests.py index 834bdee5..242e7bb2 100644 --- a/verify/src/vonage_verify/requests.py +++ b/verify/src/vonage_verify/requests.py @@ -2,7 +2,7 @@ from typing import Literal, Optional from pydantic import BaseModel, Field, model_validator -from vonage_utils.types.phone_number import PhoneNumber +from vonage_utils.types import PhoneNumber from .language_codes import LanguageCode, Psd2LanguageCode diff --git a/verify_v2/src/vonage_verify_v2/requests.py b/verify_v2/src/vonage_verify_v2/requests.py index 2aa7d124..e4869d6e 100644 --- a/verify_v2/src/vonage_verify_v2/requests.py +++ b/verify_v2/src/vonage_verify_v2/requests.py @@ -2,7 +2,7 @@ from typing import List, Optional, Union from pydantic import BaseModel, Field, field_validator, model_validator -from vonage_utils.types.phone_number import PhoneNumber +from vonage_utils.types import PhoneNumber from .enums import ChannelType, Locale from .errors import VerifyError diff --git a/voice/BUILD b/voice/BUILD new file mode 100644 index 00000000..a032d635 --- /dev/null +++ b/voice/BUILD @@ -0,0 +1,16 @@ +resource(name='pyproject', source='pyproject.toml') +file(name='readme', source='README.md') + +files(sources=['tests/data/*']) + +python_distribution( + name='vonage-voice', + dependencies=[ + ':pyproject', + ':readme', + 'voice/src/vonage_voice', + ], + provides=python_artifact(), + generate_setup=False, + repositories=['@pypi'], +) diff --git a/voice/CHANGES.md b/voice/CHANGES.md new file mode 100644 index 00000000..5d3cfbb8 --- /dev/null +++ b/voice/CHANGES.md @@ -0,0 +1,5 @@ +# 1.0.1 +- Initial upload + +# 1.0.0 +- This version was skipped due to a technical issue with the package distribution. Please use version 1.0.1 or later. \ No newline at end of file diff --git a/voice/README.md b/voice/README.md new file mode 100644 index 00000000..54828483 --- /dev/null +++ b/voice/README.md @@ -0,0 +1,9 @@ +# Vonage Voice Package + +This package contains the code to use [Vonage's Voice API](https://developer.vonage.com/en/voice/voice-api/overview) in Python. This package includes methods for working with the Voice API. It also contains an NCCO (Call Control Object) builder to help you to control call flow. + +## Usage + +It is recommended to use this as part of the main `vonage` package. The examples below assume you've created an instance of the `vonage.Vonage` class called `vonage_client`. + +### Create a Call diff --git a/voice/pyproject.toml b/voice/pyproject.toml new file mode 100644 index 00000000..1de9acac --- /dev/null +++ b/voice/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = 'vonage-voice' +version = '1.0.0' +description = 'Vonage voice package' +readme = "README.md" +authors = [{ name = "Vonage", email = "devrel@vonage.com" }] +requires-python = ">=3.8" +dependencies = [ + "vonage-http-client>=1.2.1", + "vonage-utils>=1.0.1", + "pydantic>=2.6.1", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: Apache Software License", +] + +[project.urls] +homepage = "https://github.com/Vonage/vonage-python-sdk" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/vonage_utils/src/vonage_utils/types/BUILD b/voice/src/vonage_voice/BUILD similarity index 100% rename from vonage_utils/src/vonage_utils/types/BUILD rename to voice/src/vonage_voice/BUILD diff --git a/voice/src/vonage_voice/__init__.py b/voice/src/vonage_voice/__init__.py new file mode 100644 index 00000000..2df5dea0 --- /dev/null +++ b/voice/src/vonage_voice/__init__.py @@ -0,0 +1,3 @@ +from .voice import Voice + +__all__ = ['Voice'] diff --git a/voice/src/vonage_voice/errors.py b/voice/src/vonage_voice/errors.py new file mode 100644 index 00000000..02c4cc68 --- /dev/null +++ b/voice/src/vonage_voice/errors.py @@ -0,0 +1,9 @@ +from vonage_utils.errors import VonageError + + +class VoiceError(VonageError): + """Indicates an error when using the Vonage Voice API.""" + + +class NccoActionError(VoiceError): + """Indicates an error when using an NCCO action.""" diff --git a/voice/src/vonage_voice/models/common.py b/voice/src/vonage_voice/models/common.py new file mode 100644 index 00000000..0939b6e0 --- /dev/null +++ b/voice/src/vonage_voice/models/common.py @@ -0,0 +1,9 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field + + +class AdvancedMachineDetection(BaseModel): + behavior: Optional[Literal['continue', 'hangup']] = None + mode: Optional[Literal['default', 'detect', 'detect_beep']] = 'detect' + beep_timeout: Optional[int] = Field(None, ge=45, le=120) diff --git a/voice/src/vonage_voice/models/connect_endpoints.py b/voice/src/vonage_voice/models/connect_endpoints.py new file mode 100644 index 00000000..6618f014 --- /dev/null +++ b/voice/src/vonage_voice/models/connect_endpoints.py @@ -0,0 +1,48 @@ +from pydantic import BaseModel, AnyUrl, Field +from typing import Optional +from typing_extensions import Literal + +from .enums import ConnectEndpointType + +from vonage_utils.types import Dtmf, PhoneNumber, SipUri + + +class BaseEndpoint(BaseModel): + """Base Endpoint model for use with the NCCO Connect action.""" + + +class OnAnswer(BaseModel): + url: AnyUrl + ringbackTone: Optional[AnyUrl] = None + + +class PhoneEndpoint(BaseEndpoint): + number: PhoneNumber + dtmfAnswer: Optional[Dtmf] = None + onAnswer: Optional[OnAnswer] = None + type: ConnectEndpointType = ConnectEndpointType.PHONE + + +class AppEndpoint(BaseEndpoint): + user: str + type: ConnectEndpointType = ConnectEndpointType.APP + + +class WebsocketEndpoint(BaseEndpoint): + uri: AnyUrl + contentType: Literal['audio/l16;rate=16000', 'audio/l16;rate=8000'] = Field( + 'audio/l16;rate=16000', serialization_alias='content-type' + ) + headers: Optional[dict] = {} + type: ConnectEndpointType = ConnectEndpointType.WEBSOCKET + + +class SipEndpoint(BaseEndpoint): + uri: SipUri + headers: Optional[dict] = {} + type: ConnectEndpointType = ConnectEndpointType.SIP + + +class VbcEndpoint(BaseEndpoint): + extension: str + type: ConnectEndpointType = ConnectEndpointType.VBC diff --git a/voice/src/vonage_voice/models/enums.py b/voice/src/vonage_voice/models/enums.py new file mode 100644 index 00000000..5ace21d3 --- /dev/null +++ b/voice/src/vonage_voice/models/enums.py @@ -0,0 +1,26 @@ +from enum import Enum + + +class Channel(Enum, str): + PHONE = 'phone' + SIP = 'sip' + WEBSOCKET = 'websocket' + VBC = 'vbc' + + +class NccoActionType(Enum, str): + RECORD = 'record' + CONVERSATION = 'conversation' + CONNECT = 'connect' + TALK = 'talk' + STREAM = 'stream' + INPUT = 'input' + NOTIFY = 'notify' + + +class ConnectEndpointType(Enum, str): + PHONE = 'phone' + APP = 'app' + WEBSOCKET = 'websocket' + SIP = 'sip' + VBC = 'vbc' diff --git a/voice/src/vonage_voice/models/input_types.py b/voice/src/vonage_voice/models/input_types.py new file mode 100644 index 00000000..761ba31d --- /dev/null +++ b/voice/src/vonage_voice/models/input_types.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, confloat, conint +from typing import Optional, List + + +class InputTypes: + class Dtmf(BaseModel): + timeOut: Optional[conint(ge=0, le=10)] + maxDigits: Optional[conint(ge=1, le=20)] + submitOnHash: Optional[bool] + + class Speech(BaseModel): + uuid: Optional[str] + endOnSilence: Optional[confloat(ge=0.4, le=10.0)] + language: Optional[str] + context: Optional[List[str]] + startTimeout: Optional[conint(ge=1, le=60)] + maxDuration: Optional[conint(ge=1, le=60)] + saveAudio: Optional[bool] + + @classmethod + def create_dtmf_model(cls, dict) -> Dtmf: + return cls.Dtmf.parse_obj(dict) + + @classmethod + def create_speech_model(cls, dict) -> Speech: + return cls.Speech.parse_obj(dict) diff --git a/voice/src/vonage_voice/models/ncco.py b/voice/src/vonage_voice/models/ncco.py new file mode 100644 index 00000000..c1d4d49b --- /dev/null +++ b/voice/src/vonage_voice/models/ncco.py @@ -0,0 +1,224 @@ +from pydantic import ( + AnyUrl, + BaseModel, + Field, + model_validator, + validator, + constr, + confloat, + conint, +) +from typing import Optional, Union, List +from typing_extensions import Literal + +from vonage_voice.errors import NccoActionError, VoiceError +from vonage_voice.models.common import AdvancedMachineDetection + +from .connect_endpoints import BaseEndpoint +from .enums import NccoActionType +from .input_types import InputTypes +from vonage_utils.types import PhoneNumber + + +class NccoAction(BaseModel): + """The base class for all NCCO actions. + + For more information on NCCO actions, see the Vonage API documentation.""" + + +class Record(NccoAction): + """Use the record action to record a call or part of a call.""" + + format: Optional[Literal['mp3', 'wav', 'ogg']] = None + split: Optional[Literal['conversation']] = None + channels: Optional[int] = Field(None, ge=1, le=32) + endOnSilence: Optional[int] = Field(None, ge=3, le=10) + endOnKey: Optional[str] = Field(None, pattern=r'^[0-9#*]$') + timeOut: Optional[int] = Field(None, ge=3, le=7200) + beepStart: Optional[bool] = None + eventUrl: Optional[List[AnyUrl]] = None + eventMethod: Optional[str] = None + action: NccoActionType = NccoActionType.RECORD + + @model_validator(mode='after') + def enable_split(self): + if self.channels and not self.split: + self.split = 'conversation' + return self + + +class Conversation(NccoAction): + """You can use the conversation action to create standard or moderated conferences, + while preserving the communication context. + + Using a conversation with the same name reuses the same persisted conversation.""" + + name: str + musicOnHoldUrl: Optional[List[AnyUrl]] = None + startOnEnter: Optional[bool] = True + endOnExit: Optional[bool] = False + record: Optional[bool] = None + canSpeak: Optional[List[str]] = None + canHear: Optional[List[str]] = None + mute: Optional[bool] = None + action: NccoActionType = NccoActionType.CONVERSATION + + @model_validator(mode='after') + def can_mute(self): + if self.canSpeak and self.mute: + raise NccoActionError( + 'Cannot use mute option if canSpeak option is specified.' + ) + return self + + +class Connect(NccoAction): + """You can use the connect action to connect a call to endpoints such as phone numbers or a VBC extension.""" + + endpoint: List[BaseEndpoint] + from_: Optional[PhoneNumber] = Field(None, serialization_alias='from') + randomFromNumber: Optional[bool] = False + eventType: Optional[Literal['synchronous']] = None + timeout: Optional[int] = None + limit: Optional[int] = Field(None, le=7200) + machineDetection: Optional[Literal['continue', 'hangup']] = None + advancedMachineDetection: Optional[AdvancedMachineDetection] = None + eventUrl: Optional[List[AnyUrl]] = None + eventMethod: Optional[str] = 'POST' + ringbackTone: Optional[AnyUrl] = None + action: NccoActionType = NccoActionType.CONNECT + + @model_validator(mode='after') + def validate_from_and_random_from_number(self): + if self.randomFromNumber is None and self.from_ is None: + raise VoiceError('Either `from_` or `random_from_number` must be set') + if self.randomFromNumber == True and self.from_ is not None: + raise VoiceError('`from_` and `random_from_number` cannot be used together') + return self + + +class Talk(NccoAction): + """The talk action sends synthesized speech to a Conversation.""" + + text: constr(max_length=1500) + bargeIn: Optional[bool] + loop: Optional[conint(ge=0)] + level: Optional[confloat(ge=-1, le=1)] + language: Optional[str] + style: Optional[int] + premium: Optional[bool] + action: NccoActionType = NccoActionType.TALK + + +class Stream(NccoAction): + """The stream action allows you to send an audio stream to a Conversation.""" + + streamUrl: Union[List[str], str] + level: Optional[confloat(ge=-1, le=1)] + bargeIn: Optional[bool] + loop: Optional[conint(ge=0)] + action: NccoActionType = NccoActionType.STREAM + + @validator('streamUrl') + def ensure_url_in_list(cls, v): + return Ncco._ensure_object_in_list(v) + + +class Input(NccoAction): + """Collect digits or speech input by the person you are are calling.""" + + type: Union[ + Literal['dtmf', 'speech'], + List[Literal['dtmf']], + List[Literal['speech']], + List[Literal['dtmf', 'speech']], + ] + dtmf: Optional[Union[InputTypes.Dtmf, dict]] + speech: Optional[Union[InputTypes.Speech, dict]] + eventUrl: Optional[Union[List[str], str]] + eventMethod: Optional[constr(to_upper=True)] + action: NccoActionType = NccoActionType.INPUT + + @validator('type', 'eventUrl') + def ensure_value_in_list(cls, v): + return Ncco._ensure_object_in_list(v) + + @validator('dtmf') + def ensure_input_object_is_dtmf_model(cls, v): + if type(v) is dict: + return InputTypes.create_dtmf_model(v) + else: + return v + + @validator('speech') + def ensure_input_object_is_speech_model(cls, v): + if type(v) is dict: + return InputTypes.create_speech_model(v) + else: + return v + + +class Notify(NccoAction): + """Use the notify action to send a custom payload to your event URL.""" + + payload: dict + eventUrl: Union[List[str], str] + eventMethod: Optional[constr(to_upper=True)] + action: NccoActionType = NccoActionType.NOTIFY + + @validator('eventUrl') + def ensure_url_in_list(cls, v): + return Ncco._ensure_object_in_list(v) + + +@deprecated(version='3.2.3', reason='The Pay NCCO action has been deprecated.') +class Pay(NccoAction): + """The pay action collects credit card information with DTMF input in a secure (PCI-DSS compliant) way.""" + + action = Field('pay', const=True) + amount: confloat(ge=0) + currency: Optional[constr(to_lower=True)] + eventUrl: Optional[Union[List[str], str]] + prompts: Optional[Union[List[PayPrompts.TextPrompt], PayPrompts.TextPrompt, dict]] + voice: Optional[Union[PayPrompts.VoicePrompt, dict]] + + @validator('amount') + def round_amount(cls, v): + return round(v, 2) + + @validator('eventUrl') + def ensure_url_in_list(cls, v): + return Ncco._ensure_object_in_list(v) + + @validator('prompts') + def ensure_text_model(cls, v): + if type(v) is dict: + return PayPrompts.create_text_model(v) + else: + return v + + @validator('voice') + def ensure_voice_model(cls, v): + if type(v) is dict: + return PayPrompts.create_voice_model(v) + else: + return v + + +@staticmethod +def build_ncco(*args: NccoAction, actions: List[NccoAction] = None) -> str: + ncco = [] + if actions is not None: + for action in actions: + ncco.append(action.dict(exclude_none=True)) + for action in args: + ncco.append(action.dict(exclude_none=True)) + return ncco + + +@staticmethod +def _ensure_object_in_list(obj): + if type(obj) != list: + return [obj] + else: + return obj diff --git a/voice/src/vonage_voice/models/requests.py b/voice/src/vonage_voice/models/requests.py new file mode 100644 index 00000000..4f5a08dc --- /dev/null +++ b/voice/src/vonage_voice/models/requests.py @@ -0,0 +1,79 @@ +from typing import List, Literal, Optional, Union +from pydantic import BaseModel, Field, AnyUrl, field_validator, model_validator + +from ..errors import VoiceError +from .ncco import NccoAction +from .common import AdvancedMachineDetection +from .enums import Channel +from vonage_utils.types import PhoneNumber, Dtmf, SipUri + + +class Phone(BaseModel): + """If using this model for a `from_` field, the `dtmf_answer` field is not allowed.""" + + number: PhoneNumber + dtmf_answer: Optional[Dtmf] = Field(None, serialization_alias='dtmfAnswer') + type: Channel = Channel.PHONE + + +class Sip(BaseModel): + uri: SipUri + type: Channel = Channel.SIP + + +class Websocket(BaseModel): + uri: str = Field(..., min_length=1, max_length=50) + content_type: Literal['audio/l16;rate=8000', 'audio/l16;rate=16000'] = Field( + 'audio/l16;rate=16000', serialization_alias='content-type' + ) + type: Channel = Channel.WEBSOCKET + headers: Optional[dict] = None + + +class Vbc(BaseModel): + extension: str + type: Channel = Channel.VBC + + +class Call(BaseModel): + ncco: List[NccoAction] = None + answer_url: List[AnyUrl] = None + answer_method: Optional[Literal['POST', 'GET']] = 'POST' + to: List[Union[Phone, Sip, Websocket, Vbc]] + from_: Optional[Phone] = Field(None, serialization_alias='from') + random_from_number: Optional[bool] = None + event_url: Optional[List[AnyUrl]] = None + event_method: Optional[Literal['POST', 'GET']] = None + machine_detection: Optional[Literal['continue', 'hangup']] = None + advanced_machine_detection: Optional[AdvancedMachineDetection] = None + length_timer: Optional[int] = Field(7200, ge=1, le=7200) + ringing_timer: Optional[int] = Field(60, ge=1, le=120) + + @field_validator('from_') + @classmethod + def validate_from(cls, v: Phone): + if v.dtmf_answer is not None: + v.dtmf_answer = None + return v + + @model_validator(mode='after') + def validate_ncco_and_answer_url(self): + if self.ncco is None and self.answer_url is None: + raise VoiceError('Either `ncco` or `answer_url` must be set') + if self.ncco is not None and self.answer_url is not None: + raise VoiceError('`ncco` and `answer_url` cannot be used together') + if ( + self.ncco is not None + and self.answer_url is None + and self.answer_method is not None + ): + self.answer_method = None + return self + + @model_validator(mode='after') + def validate_from_and_random_from_number(self): + if self.random_from_number is None and self.from_ is None: + raise VoiceError('Either `from_` or `random_from_number` must be set') + if self.random_from_number == True and self.from_ is not None: + raise VoiceError('`from_` and `random_from_number` cannot be used together') + return self diff --git a/voice/src/vonage_voice/models/responses.py b/voice/src/vonage_voice/models/responses.py new file mode 100644 index 00000000..305c2d9a --- /dev/null +++ b/voice/src/vonage_voice/models/responses.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + + +class CreateCallResponse(BaseModel): + uuid: str + status: str + direction: str + conversation_uuid: str + + +class CallStatus(BaseModel): + message: str + uuid: str diff --git a/voice/src/vonage_voice/voice.py b/voice/src/vonage_voice/voice.py new file mode 100644 index 00000000..c2d9f5d8 --- /dev/null +++ b/voice/src/vonage_voice/voice.py @@ -0,0 +1,30 @@ +from pydantic import validate_call +from vonage_http_client.http_client import HttpClient + +from .models.requests import Call +from .models.responses import CreateCallResponse + + +class Voice: + """Calls Vonage's Voice API.""" + + def __init__(self, http_client: HttpClient) -> None: + self._http_client = http_client + + @validate_call + def create_call(self, params: Call) -> CreateCallResponse: + """Creates a new call using the Vonage Voice API. + + Args: + params (Call): The parameters for the call. + + Returns: + CreateCallResponse: The response object containing information about the created call. + """ + response = self._http_client.post( + self._http_client.api_host, + '/v1/calls', + params.model_dump(by_alias=True, exclude_none=True), + ) + + return CreateCallResponse(**response) diff --git a/voice/tests/BUILD b/voice/tests/BUILD new file mode 100644 index 00000000..44127fb3 --- /dev/null +++ b/voice/tests/BUILD @@ -0,0 +1 @@ +python_tests(dependencies=['voice', 'testutils']) diff --git a/voice/tests/_test_models.py b/voice/tests/_test_models.py new file mode 100644 index 00000000..b1a3eb81 --- /dev/null +++ b/voice/tests/_test_models.py @@ -0,0 +1,138 @@ +from pytest import raises +from vonage_verify_v2.enums import ChannelType, Locale +from vonage_verify_v2.errors import VerifyError +from vonage_verify_v2.requests import * + + +def test_create_silent_auth_channel(): + params = { + 'channel': ChannelType.SILENT_AUTH, + 'to': '1234567890', + 'redirect_url': 'https://example.com', + 'sandbox': True, + } + channel = SilentAuthChannel(**params) + + assert channel.model_dump() == params + + +def test_create_sms_channel(): + params = { + 'channel': ChannelType.SMS, + 'to': '1234567890', + 'from_': 'Vonage', + 'entity_id': '12345678901234567890', + 'content_id': '12345678901234567890', + 'app_hash': '12345678901', + } + channel = SmsChannel(**params) + + assert channel.model_dump() == params + assert channel.model_dump(by_alias=True)['from'] == 'Vonage' + + params['from_'] = 'this.is!invalid' + with raises(VerifyError): + SmsChannel(**params) + + +def test_create_whatsapp_channel(): + params = { + 'channel': ChannelType.WHATSAPP, + 'to': '1234567890', + 'from_': 'Vonage', + } + channel = WhatsappChannel(**params) + + assert channel.model_dump() == params + assert channel.model_dump(by_alias=True)['from'] == 'Vonage' + + params['from_'] = 'this.is!invalid' + with raises(VerifyError): + WhatsappChannel(**params) + + +def test_create_voice_channel(): + params = { + 'channel': ChannelType.VOICE, + 'to': '1234567890', + } + channel = VoiceChannel(**params) + + assert channel.model_dump() == params + + +def test_create_email_channel(): + params = { + 'channel': ChannelType.EMAIL, + 'to': 'customer@example.com', + 'from_': 'vonage@vonage.com', + } + channel = EmailChannel(**params) + + assert channel.model_dump() == params + assert channel.model_dump(by_alias=True)['from'] == 'vonage@vonage.com' + + +def test_create_verify_request(): + silent_auth_channel = SilentAuthChannel( + channel=ChannelType.SILENT_AUTH, to='1234567890' + ) + + sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') + params = { + 'brand': 'Vonage', + 'workflow': [sms_channel], + } + # Basic request + + verify_request = VerifyRequest(**params) + assert verify_request.brand == 'Vonage' + assert verify_request.workflow == [sms_channel] + + # Multiple channel request + workflow = [ + SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890'), + SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage'), + WhatsappChannel(channel=ChannelType.WHATSAPP, to='1234567890', from_='Vonage'), + VoiceChannel(channel=ChannelType.VOICE, to='1234567890'), + EmailChannel(channel=ChannelType.EMAIL, to='customer@example.com'), + ] + params = { + 'brand': 'Vonage', + 'workflow': workflow, + } + verify_request = VerifyRequest(**params) + assert verify_request.brand == 'Vonage' + assert verify_request.workflow == workflow + + # All fields + params = { + 'brand': 'Vonage', + 'workflow': [silent_auth_channel, sms_channel, sms_channel], + 'locale': Locale.EN_GB, + 'channel_timeout': 60, + 'client_ref': 'my-client-ref', + 'code_length': 6, + 'code': '123456', + } + verify_request = VerifyRequest(**params) + assert verify_request.brand == 'Vonage' + assert verify_request.workflow == [silent_auth_channel, sms_channel, sms_channel] + assert verify_request.locale == Locale.EN_GB + assert verify_request.channel_timeout == 60 + assert verify_request.client_ref == 'my-client-ref' + assert verify_request.code_length == 6 + assert verify_request.code == '123456' + + +def test_create_verify_request_error(): + params = { + 'brand': 'Vonage', + 'workflow': [ + SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage'), + SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890'), + ], + } + with raises(VerifyError) as e: + VerifyRequest(**params) + assert e.match('must be the first channel') diff --git a/voice/tests/_test_verify_v2.py b/voice/tests/_test_verify_v2.py new file mode 100644 index 00000000..57da7c89 --- /dev/null +++ b/voice/tests/_test_verify_v2.py @@ -0,0 +1,184 @@ +from os.path import abspath + +import responses +from pytest import raises +from vonage_http_client.errors import HttpRequestError +from vonage_http_client.http_client import HttpClient +from vonage_verify_v2.requests import * +from vonage_verify_v2.verify_v2 import VerifyV2 + +from testutils import build_response, get_mock_jwt_auth + +path = abspath(__file__) + + +verify = VerifyV2(HttpClient(get_mock_jwt_auth())) + + +@responses.activate +def test_make_verify_request(): + build_response( + path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 + ) + silent_auth_channel = SilentAuthChannel( + channel=ChannelType.SILENT_AUTH, to='1234567890' + ) + sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') + params = { + 'brand': 'Vonage', + 'workflow': [silent_auth_channel, sms_channel], + } + request = VerifyRequest(**params) + + response = verify.start_verification(request) + assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' + assert ( + response.check_url + == 'https://api-eu-3.vonage.com/v2/verify/cfbc9a3b-27a2-40d4-a4e0-0c59b3b41901/silent-auth/redirect' + ) + assert verify._http_client.last_response.status_code == 202 + + +@responses.activate +def test_make_verify_request_full(): + build_response( + path, 'POST', 'https://api.nexmo.com/v2/verify', 'verify_request.json', 202 + ) + workflow = [ + SilentAuthChannel(channel=ChannelType.SILENT_AUTH, to='1234567890'), + SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage'), + WhatsappChannel(channel=ChannelType.WHATSAPP, to='1234567890', from_='Vonage'), + VoiceChannel(channel=ChannelType.VOICE, to='1234567890'), + EmailChannel(channel=ChannelType.EMAIL, to='customer@example.com'), + ] + params = { + 'brand': 'Vonage', + 'workflow': workflow, + 'locale': 'en-gb', + 'channel_timeout': 60, + 'client_ref': 'my-client-ref', + 'code_length': 6, + 'code': '123456', + } + request = VerifyRequest(**params) + + response = verify.start_verification(request) + assert response.request_id == '2c59e3f4-a047-499f-a14f-819cd1989d2e' + + +@responses.activate +def test_verify_request_concurrent_verifications_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify', + 'verify_request_error.json', + 409, + ) + sms_channel = SmsChannel(channel=ChannelType.SMS, to='1234567890', from_='Vonage') + params = { + 'brand': 'Vonage', + 'workflow': [sms_channel], + } + request = VerifyRequest(**params) + + with raises(HttpRequestError) as e: + verify.start_verification(request) + + assert e.value.response.status_code == 409 + assert e.value.response.json()['title'] == 'Conflict' + assert ( + e.value.response.json()['detail'] + == 'Concurrent verifications to the same number are not allowed' + ) + + +@responses.activate +def test_check_code(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code.json', + ) + response = verify.check_code( + request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234' + ) + assert response.request_id == '36e7060d-2b23-4257-bad0-773ab47f85ef' + assert response.status == 'completed' + + +@responses.activate +def test_check_code_invalid_code_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code_400.json', + 400, + ) + + with raises(HttpRequestError) as e: + verify.check_code(request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234') + + assert e.value.response.status_code == 400 + assert e.value.response.json()['title'] == 'Invalid Code' + + +@responses.activate +def test_check_code_too_many_attempts(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + 'check_code_410.json', + 410, + ) + + with raises(HttpRequestError) as e: + verify.check_code(request_id='36e7060d-2b23-4257-bad0-773ab47f85ef', code='1234') + + assert e.value.response.status_code == 410 + assert e.value.response.json()['title'] == 'Invalid Code' + + +@responses.activate +def test_cancel_verification(): + responses.add( + responses.DELETE, + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef', + status=204, + ) + assert verify.cancel_verification('36e7060d-2b23-4257-bad0-773ab47f85ef') is None + assert verify._http_client.last_response.status_code == 204 + + +@responses.activate +def test_trigger_next_workflow(): + responses.add( + responses.POST, + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef/next_workflow', + status=200, + ) + assert verify.trigger_next_workflow('36e7060d-2b23-4257-bad0-773ab47f85ef') is None + assert verify._http_client.last_response.status_code == 200 + + +@responses.activate +def test_trigger_next_event_error(): + build_response( + path, + 'POST', + 'https://api.nexmo.com/v2/verify/36e7060d-2b23-4257-bad0-773ab47f85ef/next_workflow', + 'trigger_next_workflow_error.json', + status_code=409, + ) + + with raises(HttpRequestError) as e: + verify.trigger_next_workflow('36e7060d-2b23-4257-bad0-773ab47f85ef') + + assert e.value.response.status_code == 409 + assert e.value.response.json()['title'] == 'Conflict' + assert ( + e.value.response.json()['detail'] == 'There are no more events left to trigger.' + ) diff --git a/voice/tests/data/check_code.json b/voice/tests/data/check_code.json new file mode 100644 index 00000000..2fbe4b8e --- /dev/null +++ b/voice/tests/data/check_code.json @@ -0,0 +1,4 @@ +{ + "request_id": "36e7060d-2b23-4257-bad0-773ab47f85ef", + "status": "completed" +} \ No newline at end of file diff --git a/voice/tests/data/check_code_400.json b/voice/tests/data/check_code_400.json new file mode 100644 index 00000000..23690a83 --- /dev/null +++ b/voice/tests/data/check_code_400.json @@ -0,0 +1,6 @@ +{ + "type": "https://developer.nexmo.com/api-errors#bad-request", + "title": "Invalid Code", + "detail": "The code you provided does not match the expected value.", + "instance": "475343c0-9239-4715-aed1-72b4a18379d1" +} \ No newline at end of file diff --git a/voice/tests/data/check_code_410.json b/voice/tests/data/check_code_410.json new file mode 100644 index 00000000..9d2534c7 --- /dev/null +++ b/voice/tests/data/check_code_410.json @@ -0,0 +1,6 @@ +{ + "title": "Invalid Code", + "detail": "An incorrect code has been provided too many times. Workflow terminated.", + "instance": "f79d7a15-30b7-498a-bc99-4e879b836b18", + "type": "https://developer.nexmo.com/api-errors#gone" +} \ No newline at end of file diff --git a/voice/tests/data/trigger_next_workflow_error.json b/voice/tests/data/trigger_next_workflow_error.json new file mode 100644 index 00000000..befd87a7 --- /dev/null +++ b/voice/tests/data/trigger_next_workflow_error.json @@ -0,0 +1,6 @@ +{ + "title": "Conflict", + "detail": "There are no more events left to trigger.", + "instance": "4d731cb7-25d3-487a-9ea0-f6b5811b534f", + "type": "https://developer.nexmo.com/api-errors#conflict" +} \ No newline at end of file diff --git a/voice/tests/data/verify_request.json b/voice/tests/data/verify_request.json new file mode 100644 index 00000000..719396cc --- /dev/null +++ b/voice/tests/data/verify_request.json @@ -0,0 +1,4 @@ +{ + "request_id": "2c59e3f4-a047-499f-a14f-819cd1989d2e", + "check_url": "https://api-eu-3.vonage.com/v2/verify/cfbc9a3b-27a2-40d4-a4e0-0c59b3b41901/silent-auth/redirect" +} \ No newline at end of file diff --git a/voice/tests/data/verify_request_error.json b/voice/tests/data/verify_request_error.json new file mode 100644 index 00000000..f40b1b12 --- /dev/null +++ b/voice/tests/data/verify_request_error.json @@ -0,0 +1,6 @@ +{ + "title": "Conflict", + "detail": "Concurrent verifications to the same number are not allowed", + "instance": "229ececf-382e-4ab6-b380-d8e0e830fd44", + "request_id": "f8386e0f-6873-4617-aa99-19016217b2aa" +} \ No newline at end of file diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index eef1c130..40b182e4 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,6 @@ +# 3.99.0a7 +- Add support for the [Vonage Voice API](https://developer.vonage.com/en/voice/voice-api/overview). + # 3.99.0a6 - Add support for the [Vonage Messages API](https://developer.vonage.com/en/messages/overview). diff --git a/vonage/pyproject.toml b/vonage/pyproject.toml index 06f1059b..20b8ba97 100644 --- a/vonage/pyproject.toml +++ b/vonage/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "vonage-users>=1.0.1", "vonage-verify>=1.0.1", "vonage-verify-v2>=1.0.0", + "vonage-voice>=1.0.1", ] classifiers = [ "Programming Language :: Python", diff --git a/vonage/src/vonage/__init__.py b/vonage/src/vonage/__init__.py index 4005985f..fcd6bcaa 100644 --- a/vonage/src/vonage/__init__.py +++ b/vonage/src/vonage/__init__.py @@ -9,6 +9,7 @@ Users, Verify, VerifyV2, + Voice, Vonage, ) @@ -22,5 +23,6 @@ 'Users', 'Verify', 'VerifyV2', + 'Voice', 'VonageError', ] diff --git a/vonage/src/vonage/vonage.py b/vonage/src/vonage/vonage.py index e8d18a7f..9f599778 100644 --- a/vonage/src/vonage/vonage.py +++ b/vonage/src/vonage/vonage.py @@ -7,6 +7,7 @@ from vonage_users import Users from vonage_verify import Verify from vonage_verify_v2 import VerifyV2 +from vonage_voice import Voice from ._version import __version__ @@ -35,6 +36,7 @@ def __init__( self.users = Users(self._http_client) self.verify = Verify(self._http_client) self.verify_v2 = VerifyV2(self._http_client) + self.voice = Voice(self._http_client) @property def http_client(self): diff --git a/vonage_utils/src/vonage_utils/__init__.py b/vonage_utils/src/vonage_utils/__init__.py index db619eb7..a0103b6a 100644 --- a/vonage_utils/src/vonage_utils/__init__.py +++ b/vonage_utils/src/vonage_utils/__init__.py @@ -1,5 +1,6 @@ +import types + from .errors import VonageError -from .types.phone_number import PhoneNumber from .utils import format_phone_number, remove_none_values -__all__ = ['VonageError', 'format_phone_number', 'remove_none_values', PhoneNumber] +__all__ = ['VonageError', 'format_phone_number', 'remove_none_values', 'types'] diff --git a/vonage_utils/src/vonage_utils/types/phone_number.py b/vonage_utils/src/vonage_utils/types.py similarity index 50% rename from vonage_utils/src/vonage_utils/types/phone_number.py rename to vonage_utils/src/vonage_utils/types.py index 88e4da45..63bd319c 100644 --- a/vonage_utils/src/vonage_utils/types/phone_number.py +++ b/vonage_utils/src/vonage_utils/types.py @@ -2,3 +2,5 @@ from typing_extensions import Annotated PhoneNumber = Annotated[str, Field(pattern=r'^[1-9]\d{6,14}$')] +Dtmf = Annotated[str, Field(pattern=r'^[0-9#*p]+$')] +SipUri = Annotated[str, Field(pattern=r'^(sip|sips):\+?([\w|:.\-@;,=%&]+)')] diff --git a/vonage_utils/src/vonage_utils/types/__init__.py b/vonage_utils/src/vonage_utils/types/__init__.py deleted file mode 100644 index e69de29b..00000000