diff --git a/Call-Automation-Appointment-Remainder/Audio/AddParticipant.wav b/Call-Automation-Appointment-Remainder/Audio/AddParticipant.wav new file mode 100644 index 0000000..7491fbd Binary files /dev/null and b/Call-Automation-Appointment-Remainder/Audio/AddParticipant.wav differ diff --git a/Call-Automation-Appointment-Remainder/Audio/AgentAudio.wav b/Call-Automation-Appointment-Remainder/Audio/AgentAudio.wav new file mode 100644 index 0000000..522006c Binary files /dev/null and b/Call-Automation-Appointment-Remainder/Audio/AgentAudio.wav differ diff --git a/Call-Automation-Appointment-Remainder/Audio/AppointmentCancelledAudio.wav b/Call-Automation-Appointment-Remainder/Audio/AppointmentCancelledAudio.wav new file mode 100644 index 0000000..cc9e1c7 Binary files /dev/null and b/Call-Automation-Appointment-Remainder/Audio/AppointmentCancelledAudio.wav differ diff --git a/Call-Automation-Appointment-Remainder/Audio/AppointmentConfirmedAudio.wav b/Call-Automation-Appointment-Remainder/Audio/AppointmentConfirmedAudio.wav new file mode 100644 index 0000000..13b92ec Binary files /dev/null and b/Call-Automation-Appointment-Remainder/Audio/AppointmentConfirmedAudio.wav differ diff --git a/Call-Automation-Appointment-Remainder/Audio/AppointmentReminderMenu.wav b/Call-Automation-Appointment-Remainder/Audio/AppointmentReminderMenu.wav new file mode 100644 index 0000000..f119e03 Binary files /dev/null and b/Call-Automation-Appointment-Remainder/Audio/AppointmentReminderMenu.wav differ diff --git a/Call-Automation-Appointment-Remainder/Audio/InvalidInputAudio.wav b/Call-Automation-Appointment-Remainder/Audio/InvalidInputAudio.wav new file mode 100644 index 0000000..3dfa656 Binary files /dev/null and b/Call-Automation-Appointment-Remainder/Audio/InvalidInputAudio.wav differ diff --git a/Call-Automation-Appointment-Remainder/Audio/RemoveParticipant.wav b/Call-Automation-Appointment-Remainder/Audio/RemoveParticipant.wav new file mode 100644 index 0000000..9b6f577 Binary files /dev/null and b/Call-Automation-Appointment-Remainder/Audio/RemoveParticipant.wav differ diff --git a/Call-Automation-Appointment-Remainder/Audio/TimedoutAudio.wav b/Call-Automation-Appointment-Remainder/Audio/TimedoutAudio.wav new file mode 100644 index 0000000..fb76eeb Binary files /dev/null and b/Call-Automation-Appointment-Remainder/Audio/TimedoutAudio.wav differ diff --git a/Call-Automation-Appointment-Remainder/CallConfiguration.py b/Call-Automation-Appointment-Remainder/CallConfiguration.py new file mode 100644 index 0000000..721385a --- /dev/null +++ b/Call-Automation-Appointment-Remainder/CallConfiguration.py @@ -0,0 +1,19 @@ +#from EventHandler.EventAuthHandler import EventAuthHandler + + +class CallConfiguration: + + def __init__(self, connection_string, source_identity, source_phone_number, app_base_url, audio_file_name,Event_CallBack_Route,Appointment_Confirmed_Audio, + Appointment_Cancelled_Audio,Timed_out_Audio,Invalid_Input_Audio): + self.connection_string: str = str(connection_string) + self.source_identity: str = str(source_identity) + self.source_phone_number: str = str(source_phone_number) + self.app_base_url: str = str(app_base_url) + self.audio_file_name: str = str(audio_file_name) + self.Appointment_Confirmed_Audio: str = str(Appointment_Confirmed_Audio) + self.Appointment_Cancelled_Audio: str = str(Appointment_Cancelled_Audio) + self.Timed_out_Audio: str = str(Timed_out_Audio) + self.Invalid_Input_Audio: str = str(Invalid_Input_Audio) + self.Event_CallBack_Route:str = str(Event_CallBack_Route) + self.app_callback_url: str = app_base_url + Event_CallBack_Route + self.audio_file_url: str = audio_file_name diff --git a/Call-Automation-Appointment-Remainder/ConfigurationManager.py b/Call-Automation-Appointment-Remainder/ConfigurationManager.py new file mode 100644 index 0000000..0dde1c0 --- /dev/null +++ b/Call-Automation-Appointment-Remainder/ConfigurationManager.py @@ -0,0 +1,22 @@ +import configparser +class ConfigurationManager: + __configuration = None + __instance = None + _configpath=None + + def __init__(self): + if(self.__configuration == None): + self.__configuration = configparser.ConfigParser() + self.__configuration.read('config.ini') + + @staticmethod + def get_instance(): + + if(ConfigurationManager.__instance == None): + ConfigurationManager.__instance = ConfigurationManager() + return ConfigurationManager.__instance + + def get_app_settings(self, key): + if (key != None): + return self.__configuration.get("DEFAULT", key) + return None diff --git a/Call-Automation-Appointment-Remainder/Logger.py b/Call-Automation-Appointment-Remainder/Logger.py new file mode 100644 index 0000000..3014d46 --- /dev/null +++ b/Call-Automation-Appointment-Remainder/Logger.py @@ -0,0 +1,12 @@ +import enum + + +class Logger(enum.Enum): + + INFORMATION = 1 + ERROR = 2 + + @staticmethod + def log_message(message_type, message): + log_message = message_type.name + " : " + message + print(log_message) diff --git a/Call-Automation-Appointment-Remainder/config.ini b/Call-Automation-Appointment-Remainder/config.ini new file mode 100644 index 0000000..5eed2c6 --- /dev/null +++ b/Call-Automation-Appointment-Remainder/config.ini @@ -0,0 +1,32 @@ +# app settings +[DEFAULT] +#NgrokExePath = C:/ +Connectionstring=%Connectionstring% +# +SourcePhone=%SourcePhone% +# +targetIdentity=%targetIdentity% + + +EventCallBackRoute= /api/callbacks +AppBaseUri= %AppBaseUri% +AppointmentReminderMenuAudio= /Audio/AppointmentReminderMenu.wav +# AddParticipantNumber= +918374734062 +AppointmentConfirmedAudio= /Audio/AppointmentConfirmedAudio.wav +AppointmentCancelledAudio= /Audio/AppointmentCancelledAudio.wav +AgentAudio= /Audio/AgentAudio.wav +InvalidInputAudio= /Audio/InvalidInputAudio.wav +TimedoutAudio = /Audio/TimedoutAudio.wav +# AddParticipant= /audio/AddParticipant.wav +# RemoveParticipant= /audio/RemoveParticipant.wav + + + #1= Hangup for eveyone after adding participant + #2= Hangup CA after adding participant + #3= Remove addedd participants after adding them +#HangUpScenarios= 1 + + + + diff --git a/Call-Automation-Appointment-Remainder/program.py b/Call-Automation-Appointment-Remainder/program.py new file mode 100644 index 0000000..8ed6af9 --- /dev/null +++ b/Call-Automation-Appointment-Remainder/program.py @@ -0,0 +1,211 @@ +import asyncio +import re +from typing import Self +import nest_asyncio +import uuid +import azure +import azure.communication +from azure.core.messaging import CloudEvent +from azure.communication.identity._shared.models import CommunicationIdentifier,PhoneNumberIdentifier,\ + CommunicationUserIdentifier,CommunicationIdentifierKind +from azure.cognitiveservices.speech import AudioDataStream, SpeechConfig, SpeechSynthesizer, SpeechSynthesisOutputFormat +import json +from aiohttp import web +from Logger import Logger +from ConfigurationManager import ConfigurationManager +from CallConfiguration import CallConfiguration +from azure.communication.identity import CommunicationIdentityClient +from azure.communication.callautomation import CallAutomationClient,CallInvite,\ +CallAutomationEventParser,CallConnected,CallMediaRecognizeOptions,CallMediaRecognizeDtmfOptions,\ +CallConnectionClient,CallDisconnected,PlaySource,FileSource,ParticipantsUpdated,DtmfTone,\ +RecognizeCanceled,RecognizeCompleted,RecognizeFailed,AddParticipantFailed,AddParticipantSucceeded,\ + PlayCompleted,PlayFailed,RemoveParticipantSucceeded,RemoveParticipantFailed + + +class Program(): + + Target_number = None + ngrok_url = None + call_configuration: CallConfiguration = None + calling_Automation_client: CallAutomationClient = None + call_connection: CallConnectionClient = None + configuration_manager = None + user_identity_regex: str = '8:acs:[0-9a-fA-F]{8}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{12}_[0-9a-fA-F]{8}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{12}' + phone_identity_regex: str = '^\\+\\d{10,14}$' + + def get_identifier_kind(self, participantnumber: str): + # checks the identity type returns as string + if(re.search(self.user_identity_regex, participantnumber)): + return CommunicationIdentifierKind.COMMUNICATION_USER + elif(re.search(self.phone_identity_regex, participantnumber)): + return CommunicationIdentifierKind.PHONE_NUMBER + else: + return CommunicationIdentifierKind.UNKNOWN + configuration_manager = ConfigurationManager.get_instance() + calling_Automation_client = CallAutomationClient.from_connection_string(configuration_manager.get_app_settings('Connectionstring')) + ngrok_url =configuration_manager.get_app_settings('AppBaseUri') + + #call_configuration = initiate_configuration(Self,ngrok_url) + + def __init__(self): + Logger.log_message(Logger.INFORMATION, 'Starting ACS Sample App ') + # Get configuration properties + self.app = web.Application() + + self.app.add_routes([web.post('/api/call',self.run_sample)]) + self.app.add_routes([web.get('/Audio/{file_name}', self.load_file)]) + self.app.add_routes([web.post('/api/callbacks',self.start_callBack)]) + web.run_app(self.app, port=8080) + + + async def run_sample(self,request): + self.call_configuration =self.initiate_configuration(self.ngrok_url) + try: + target_Id = self.configuration_manager.get_app_settings('targetIdentity') + + if(target_Id and len(target_Id)): + source_caller_id = CommunicationUserIdentifier(self.call_configuration.source_identity) + #source_caller_id_number=CommunicationIdentifier(raw_id = call_configuration.source_identity) + target_Identity = self.get_identifier_kind(target_Id) + if target_Identity == CommunicationIdentifierKind.COMMUNICATION_USER : + self.Target_number=CommunicationUserIdentifier(target_Id) + Callinvite=CallInvite(self.Target_number) + if target_Identity == CommunicationIdentifierKind.PHONE_NUMBER : + self.Target_number=PhoneNumberIdentifier(target_Id) + Callinvite=CallInvite(self.Target_number,sourceCallIdNumber=PhoneNumberIdentifier(self.call_configuration.source_phone_number)) + + Logger.log_message(Logger.INFORMATION,'Performing CreateCall operation') + self.call_connection_Response = CallAutomationClient.create_call(self.calling_Automation_client ,Callinvite,callback_uri=self.call_configuration.app_callback_url) + Logger.log_message( + Logger.INFORMATION, 'Call initiated with Call Leg id -- >' + self.call_connection_Response.call_connection.call_connection_id) + + + except Exception as ex: + Logger.log_message( + Logger.ERROR, 'Failure occured while creating/establishing the call. Exception -- > ' + str(ex)) + + async def start_callBack(self,request): + try: + content = await request.content.read() + event = CallAutomationEventParser.parse(content) + call_Connection = self.calling_Automation_client.get_call_connection(event.call_connection_id) + call_Connection_Media =call_Connection.get_call_media() + if event.__class__ == CallConnected: + Logger.log_message(Logger.INFORMATION,'CallConnected event received for call connection id --> ' + + event.call_connection_id) + + recognize_Options = CallMediaRecognizeDtmfOptions(self.Target_number,max_tones_to_collect=1) + recognize_Options.interrupt_prompt = True + recognize_Options.inter_tone_timeout = 30 + recognize_Options.initial_silence_timeout=5 + File_source=FileSource(uri=(self.call_configuration.app_base_url + self.call_configuration.audio_file_url)) + File_source.play_source_id= 'AppointmentReminderMenu' + recognize_Options.play_prompt = File_source + recognize_Options.operation_context= 'AppointmentReminderMenu' + call_Connection_Media.start_recognizing(recognize_Options) + + if event.__class__ == RecognizeCompleted and event.operation_context == 'AppointmentReminderMenu' : + Logger.log_message(Logger.INFORMATION,'RecognizeCompleted event received for call connection id --> '+ event.call_connection_id + +'Correlation id:'+event.correlation_id) + toneDetected=event.collect_tones_result.tones[0] + if toneDetected == DtmfTone.ONE: + playSource = FileSource(uri=(self.call_configuration.app_base_url+self.call_configuration.Appointment_Confirmed_Audio)) + PlayOption = call_Connection_Media.play_to_all(playSource,content='ResponseToDtmf') + elif toneDetected == DtmfTone.TWO : + playSource = FileSource(uri=(self.call_configuration.app_base_url+self.call_configuration.Appointment_Cancelled_Audio)) + PlayOption = call_Connection_Media.play_to_all(playSource,content='ResponseToDtmf') + else: + playSource = FileSource(uri=(self.call_configuration.app_base_url+self.call_configuration.Invalid_Input_Audio)) + call_Connection_Media.play_to_all(playSource) + + + if event.__class__ == RecognizeFailed and event.operation_context == 'AppointmentReminderMenu' : + Logger.log_message(Logger.INFORMATION,'Recognition timed out for call connection id --> '+ event.call_connection_id + +'Correlation id:'+event.correlation_id) + playSource = FileSource(uri=(self.call_configuration.app_base_url+self.call_configuration.Timed_out_Audio)) + call_Connection_Media.play_to_all(playSource) + if event.__class__ == PlayCompleted: + Logger.log_message(Logger.INFORMATION,'PlayCompleted event received for call connection id --> '+ event.call_connection_id + +'Call Connection Properties :'+event.correlation_id) + call_Connection.hang_up(True) + if event.__class__ == PlayFailed: + Logger.log_message(Logger.INFORMATION,'PlayFailed event received for call connection id --> '+ event.call_connection_id + +'Call Connection Properties :'+event.correlation_id) + call_Connection.hang_up(True) + if event.__class__ == ParticipantsUpdated : + Logger.log_message(Logger.INFORMATION,'Participants Updated --> ') + if event.__class__ == CallDisconnected : + Logger.log_message(Logger.INFORMATION,'Call Disconnected event received for call connection id --> ' + + event.call_connection_id) + + except Exception as ex: + Logger.log_message( + Logger.ERROR, 'Failed to start Audio --> ' + str(ex)) + + + # + # Fetch configurations from App Settings and create source identity + # + # The base url of the app. + # The + + def initiate_configuration(self, app_base_url): + try: + connection_string = self.configuration_manager.get_app_settings('Connectionstring') + source_phone_number = self.configuration_manager.get_app_settings('SourcePhone') + Event_CallBack_Route=self.configuration_manager.get_app_settings('EventCallBackRoute') + source_identity = self.create_user(connection_string) + audio_file_name = self.configuration_manager.get_app_settings('AppointmentReminderMenuAudio') + Appointment_Confirmed_Audio = self.configuration_manager.get_app_settings('AppointmentConfirmedAudio') + Appointment_Cancelled_Audio = self.configuration_manager.get_app_settings('AppointmentCancelledAudio') + Timed_out_Audio = self.configuration_manager.get_app_settings('TimedoutAudio') + Invalid_Input_Audio = self.configuration_manager.get_app_settings('InvalidInputAudio') + + return CallConfiguration(connection_string, source_identity, source_phone_number, app_base_url, + audio_file_name,Event_CallBack_Route,Appointment_Confirmed_Audio, + Appointment_Cancelled_Audio,Timed_out_Audio,Invalid_Input_Audio) + except Exception as ex: + Logger.log_message( + Logger.ERROR, 'Failed to CallConfiguration. Exception -- > ' + str(ex)) + + # + # Get .wav Audio file + # + + async def on_incoming_request_async(self, request): + param = request.rel_url.query + content = await request.content.read() + return 'OK' + + + async def load_file(self, request): + file_name = request.match_info.get('file_name', 'Anonymous') + resp = web.FileResponse(f'Audio/{file_name}') + return resp + # web.run_app(self.app, port=8080) + + + def create_user(self, connection_string): + client = CommunicationIdentityClient.from_connection_string( + connection_string) + user: CommunicationIdentifier = client.create_user() + return user.properties.get('id') + # + # Delete the user + # + + def delete_user(self, connection_string, source): + client = CommunicationIdentityClient.from_connection_string( + connection_string) + user = CommunicationUserIdentifier(source) + client.delete_user(user) + + +if __name__ == '__main__': + Program() +# nest_asyncio.apply() +# obj = Program() +# asyncio.run(obj.program()) + + + diff --git a/Call-Automation-Appointment-Remainder/readme.md b/Call-Automation-Appointment-Remainder/readme.md new file mode 100644 index 0000000..286ccf5 --- /dev/null +++ b/Call-Automation-Appointment-Remainder/readme.md @@ -0,0 +1,52 @@ +--- +page_type: sample +languages: +- python +products: +- azure +- azure-communication-services +--- + +# Outbound Reminder Call Sample + +This sample application shows how the Azure Communication Services Server, Calling package can be used to build IVR related solutions. This sample makes an outbound call to a phone number or a communication identifier and plays an audio message. If the callee presses 1 (tone1), to reschedule an appointment, then the application invites a new participant and then leaves the call. If the callee presses any other key then the application ends the call. This sample application is also capable of making multiple concurrent outbound calls. +The application is a console based application build using Python 3.9. + +## Getting started + +### Prerequisites + +- Create an Azure account with an active subscription. For details, see [Create an account for free](https://azure.microsoft.com/free/) +- [Python](https://www.python.org/downloads/) 3.9 and above +- Create an Azure Communication Services resource. For details, see [Create an Azure Communication Resource](https://docs.microsoft.com/azure/communication-services/quickstarts/create-communication-resource). You'll need to record your resource **connection string** for this sample. +- Get a phone number for your new Azure Communication Services resource. For details, see [Get a phone number](https://docs.microsoft.com/azure/communication-services/quickstarts/telephony-sms/get-phone-number?pivots=platform-azp) +- Download and install [Ngrok](https://www.ngrok.com/download). As the sample is run locally, Ngrok will enable the receiving of all the events. +- Download and install [Visual C++](https://support.microsoft.com/en-us/topic/the-latest-supported-visual-c-downloads-2647da03-1eea-4433-9aff-95f26a218cc0) +- (Optional) Create Azure Speech resource for generating custom message to be played by application. Follow [here](https://docs.microsoft.com/azure/cognitive-services/speech-service/overview#try-the-speech-service-for-free) to create the resource. + +> Note: the samples make use of the Microsoft Cognitive Services Speech SDK. By downloading the Microsoft Cognitive Services Speech SDK, you acknowledge its license, see [Speech SDK license agreement](https://aka.ms/csspeech/license201809). + +### Configuring application + +- Open the config.ini file to configure the following settings + + - Connection String: Azure Communication Service resource's connection string. + - Source Phone: Phone number associated with the Azure Communication Service resource. + - DestinationIdentities: Multiple sets of outbound target and Transfer target. These sets are seperated by a semi-colon, and outbound target and Transfer target in a each set are seperated by a coma. + + Format: "OutboundTarget1(PhoneNumber),TransferTarget1(PhoneNumber/MRI);OutboundTarget2(PhoneNumber),TransferTarget2(PhoneNumber/MRI);OutboundTarget3(PhoneNumber),TransferTarget3(PhoneNumber/MRI)". + + For e.g. "+1425XXXAAAA,8:acs:ab12b0ea-85ea-4f83-b0b6-84d90209c7c4_00000009-bce0-da09-54b7-xxxxxxxxxxxx;+1425XXXBBBB,+1425XXXCCCC" + + - NgrokExePath: Folder path where ngrok.exe is insalled/saved. + - SecretPlaceholder: Secret/Password that would be part of callback and will be use to validate incoming requests. + - CognitiveServiceKey: (Optional) Cognitive service key used for generating custom message + - CognitiveServiceRegion: (Optional) Region associated with cognitive service + - CustomMessage: (Optional) Text for the custom message to be converted to speech. + +### Run the Application + +- Add azure communication callingserver's wheel file path in requirement.txt +- Navigate to the directory containing the requirements.txt file and use the following commands for installing all the dependencies and for running the application respectively: + - pip install -r requirements.txt + - python program.py diff --git a/Call-Automation-Appointment-Remainder/requirements.txt b/Call-Automation-Appointment-Remainder/requirements.txt new file mode 100644 index 0000000..d75c3db --- /dev/null +++ b/Call-Automation-Appointment-Remainder/requirements.txt @@ -0,0 +1,31 @@ +aiohttp==3.7.4.post0 +async-timeout==3.0.1 +attrs==21.2.0 +azure-cognitiveservices-speech==1.27.0 +azure-common==1.1.27 +azure-communication-callautomation @file:///C:/pyenv311/azure_communication_callautomation-1.0.0a20230413001-py3-none-any.whl +azure-communication-chat==1.1.0 +azure-communication-identity==1.3.1 +azure-core==1.26.4 +azure-nspkg==3.0.2 +azure-storage==0.36.0 +certifi==2021.5.30 +cffi==1.15.1 +chardet==4.0.0 +charset-normalizer==2.0.4 +cryptography==3.4.8 +idna==3.2 +isodate==0.6.0 +msrest==0.7.1 +multidict==6.0.4 +nest-asyncio==1.5.1 +oauthlib==3.1.1 +psutil==5.8.0 +pycparser==2.20 +python-dateutil==2.8.2 +requests==2.26.0 +requests-oauthlib==1.3.0 +six==1.16.0 +typing-extensions==4.3.0 +urllib3==1.26.6 +yarl==1.8.2 \ No newline at end of file diff --git a/ServerRecording/.deployment b/ServerRecording/.deployment new file mode 100644 index 0000000..6278331 --- /dev/null +++ b/ServerRecording/.deployment @@ -0,0 +1,2 @@ +[config] +SCM_DO_BUILD_DURING_DEPLOYMENT=true \ No newline at end of file diff --git a/ServerRecording/App.py b/ServerRecording/App.py new file mode 100644 index 0000000..03fc2d8 --- /dev/null +++ b/ServerRecording/App.py @@ -0,0 +1,13 @@ +from Logger import Logger +from Controller.CallRecordingController import CallRecordingController + + +class App(): + + def __init__(): + Logger.log_message(Logger.INFORMATION, + "Starting Server recording App... ") + + +if __name__ == "__main__": + CallRecordingController() diff --git a/ServerRecording/BlobStorageHelper.py b/ServerRecording/BlobStorageHelper.py new file mode 100644 index 0000000..dfe1fc0 --- /dev/null +++ b/ServerRecording/BlobStorageHelper.py @@ -0,0 +1,73 @@ +from azure.storage.blob import BlobServiceClient, generate_blob_sas, BlobSasPermissions +from datetime import datetime, timedelta +from Logger import Logger + + +class BlobStorageHelper(): + + def upload_file_to_storage( + container_name: str, + blob_name: str, + blob_connection_string: str + ): + blob_service_client = BlobServiceClient.from_connection_string( + blob_connection_string) + container_client = blob_service_client.get_container_client( + container=container_name) + if container_client and not container_client.exists(): + return 'Blob Container -> ' + container_name + ' is unavailable' + + blob_client = blob_service_client.get_blob_client( + container=container_name, blob=blob_name) + if blob_client and blob_client.exists(): + return 'Blob -> ' + blob_name + ' already exists' + + if blob_client: + with open(blob_name, "rb") as data: + blob_client.upload_blob(data.read()) + else: + Logger.log_message( + Logger.ERROR, "Blob client instantiation failed --- >") + return "Blob client instantiation failed" + + Logger.log_message(Logger.INFORMATION, "File " + blob_name + + " __________ uploaded to Blob storage successfully --- >") + return True + + def get_blob_sas_token( + account_name: str, + account_key: str, + container_name: str, + blob_name: str + ): + try: + return generate_blob_sas( + account_name=account_name, + container_name=container_name, + blob_name=blob_name, + account_key=account_key, + permission=BlobSasPermissions(read=True), + expiry=datetime.utcnow() + timedelta(hours=1)) + except Exception as ex: + Logger.log_message(Logger.ERROR, str(ex)) + return False + + def get_blob_sas_uri( + account_name: str, + account_key: str, + container_name: str, + blob_name: str + ): + blob_sas_token = BlobStorageHelper.get_blob_sas_token( + account_name, + account_key, + container_name, + blob_name) + + blob_uri_template = 'https://{account_name}.blob.core.windows.net/{container_name}/{blob_name}?{blob_sas_token}' + + return blob_uri_template.format( + account_name=account_name, + container_name=container_name, + blob_name=blob_name, + blob_sas_token=blob_sas_token) diff --git a/ServerRecording/ConfigurationManager.py b/ServerRecording/ConfigurationManager.py new file mode 100644 index 0000000..8e290c2 --- /dev/null +++ b/ServerRecording/ConfigurationManager.py @@ -0,0 +1,23 @@ +import configparser + + +class ConfigurationManager: + __configuration = None + __instance = None + + def __init__(self): + if (self.__configuration == None): + self.__configuration = configparser.ConfigParser() + self.__configuration.read('config.ini') + + @staticmethod + def get_instance(): + if (ConfigurationManager.__instance == None): + ConfigurationManager.__instance = ConfigurationManager() + + return ConfigurationManager.__instance + + def get_app_settings(self, key): + if (key != None): + return self.__configuration.get('DEFAULT', key) + return None diff --git a/ServerRecording/Controller/CallRecordingController.py b/ServerRecording/Controller/CallRecordingController.py new file mode 100644 index 0000000..40edc66 --- /dev/null +++ b/ServerRecording/Controller/CallRecordingController.py @@ -0,0 +1,434 @@ +from azure.eventgrid import EventGridEvent +from azure.core.messaging import CloudEvent +from BlobStorageHelper import BlobStorageHelper +from ConfigurationManager import ConfigurationManager +from Logger import Logger +import json +import ast +from aiohttp import web +from azure.communication.callingserver import CallingServerClient, ServerCallLocator +from Root import Root +from FileFormats import FileFormat, Mapper +from ServerRecording.FileFormats import DownloadType + +CALL_RECORDING_ACTIVE_ERROR_CODE = "8553" +CALL_RECODING_NOT_FOUND_ERROR_CODE = "8522" +INVALID_JOIN_IDENTITY_ERROR_CODE = "8527" +CALL_NOT_ESTABLISHED_ERROR_CODE = "8501" +CALL_RECORDING_ACTIVE_ERROR = "Recording is already in progress, one recording can be active at one time." +configuration_manager = ConfigurationManager.get_instance() +connection_string = configuration_manager.get_app_settings("Connectionstring") +blob_connection_string = configuration_manager.get_app_settings( + "BlobStorageConnectionString") +container_name = configuration_manager.get_app_settings("ContainerName") +calling_server_client = CallingServerClient.from_connection_string( + connection_string) +call_back_uri = configuration_manager.get_app_settings('CallbackUri') +blob_storage_account_name = configuration_manager.get_app_settings( + 'BlobStorageAccountName') +blob_storage_account_key = configuration_manager.get_app_settings( + 'BlobStorageAccountKey') +recording_data = {} + + +class CallRecordingController(): + + recFileFormat = '' + + def __init__(self): + app = web.Application() + app.add_routes( + [web.get('/startRecording', CallRecordingController.start_recording)]) + app.add_routes( + [web.get('/startRecordingWithOptions', CallRecordingController.start_recording_with_options)]) + app.add_routes( + [web.get('/pauseRecording', CallRecordingController.pause_recording)]) + app.add_routes( + [web.get('/resumeRecording', CallRecordingController.resume_recording)]) + app.add_routes( + [web.get('/stopRecording', CallRecordingController.stop_recording)]) + app.add_routes( + [web.get('/getRecordingState', CallRecordingController.get_recording_state)]) + app.add_routes( + [web.post('/getRecordingFile', CallRecordingController.get_recording_file)]) + app.add_routes( + [web.get('/getBlobSASUri', CallRecordingController.get_blob_sas_uri)]) + app.add_routes([web.get('/', CallRecordingController.startup)]) + web.run_app(app, port=5000) + + async def start_recording(request): + try: + server_call_id = request.rel_url.query['serverCallId'] + Logger.log_message( + Logger.INFORMATION, + 'StartRecording called with serverCallId --> ' + server_call_id) + + if not server_call_id: + return web.Response(text="serverCallId is invalid", status=400) + + call_locator = ServerCallLocator(server_call_id) + res = calling_server_client.start_recording(call_locator=call_locator, + recording_state_callback_uri=call_back_uri) + + Logger.log_message( + Logger.INFORMATION, + "StartRecording response --> " + str(res) + ", Recording Id: " + res.recording_id) + + if server_call_id not in recording_data.keys(): + recording_data[server_call_id] = '' + recording_data[server_call_id] = res.recording_id + + return web.Response(text=res.recording_id) + except Exception as ex: + Logger.log_message( + Logger.ERROR, "Failed to start server recording --> " + str(ex)) + if CALL_RECORDING_ACTIVE_ERROR_CODE in str(ex) or \ + INVALID_JOIN_IDENTITY_ERROR_CODE in str(ex) or \ + CALL_NOT_ESTABLISHED_ERROR_CODE in str(ex): + return web.Response(text=str(ex), status=400) + + return web.Response(text=str(ex), status=500) + + async def start_recording_with_options(request): + try: + server_call_id = request.rel_url.query['serverCallId'] + + if not server_call_id: + return web.Response(text="serverCallId is invalid", status=400) + + server_content_type = request.rel_url.query['recordingContent'] + server_channel_type = request.rel_url.query['recordingChannel'] + server_format_type = request.rel_url.query['recordingFormat'] + + Logger.log_message( + Logger.INFORMATION, + 'StartRecording with channel called with serverCallId --> ' + + server_call_id + ' ,recordingContent --> ' + + server_content_type + ' ,recordingChannel ---> ' + server_channel_type + ' ,recordingFormat ---> ' + server_format_type) + + call_locator = ServerCallLocator(server_call_id) + mapper = Mapper() + content_type = server_content_type if server_content_type in mapper.rec_content else 'audiovideo' + channel_type = server_channel_type if server_channel_type in mapper.rec_channel else 'mixed' + format_type = server_format_type if server_format_type in mapper.rec_format else 'mp4' + + #Passing RecordingContent initiates recording in specific format. audio/audiovideo + #RecordingChannel is used to pass the channel type. mixed/unmixed + #RecordingFormat is used to pass the format of the recording. mp4/mp3/wav + + res = calling_server_client.start_recording(call_locator=call_locator, + recording_state_callback_uri=call_back_uri, + recording_content_type=content_type, + recording_channel_type=channel_type, + recording_format_type=format_type) + + Logger.log_message( + Logger.INFORMATION, + "StartRecording with channel response --> " + str(res) + ", Recording Id: " + res.recording_id) + + if server_call_id not in recording_data.keys(): + recording_data[server_call_id] = '' + recording_data[server_call_id] = res.recording_id + + return web.Response(text=res.recording_id) + except Exception as ex: + Logger.log_message( + Logger.ERROR, "Failed to start server recording with channel info--> " + str(ex)) + if CALL_RECORDING_ACTIVE_ERROR_CODE in str(ex) or \ + INVALID_JOIN_IDENTITY_ERROR_CODE in str(ex) or \ + CALL_NOT_ESTABLISHED_ERROR_CODE in str(ex): + return web.Response(text=str(ex), status=400) + + return web.Response(text=str(ex), status=500) + + async def pause_recording(request): + try: + server_call_id = request.rel_url.query['serverCallId'] + recording_id = request.rel_url.query['recordingId'] + + Logger.log_message( + Logger.INFORMATION, + 'PauseRecording called with serverCallId --> ' + server_call_id + ' and recordingId --> ' + recording_id) + + if not server_call_id: + return web.Response(text="serverCallId is invalid", status=400) + + if not recording_id: + recording_id = recording_data[server_call_id] + if not recording_id: + return web.Response(text="recordingId is invalid", status=400) + elif server_call_id not in recording_data.keys(): + recording_data[server_call_id] = recording_id + + res = calling_server_client.pause_recording( + recording_id=recording_id) + + Logger.log_message(Logger.INFORMATION, + "PauseRecording response --> " + str(res)) + return web.Response(text="OK") + except Exception as ex: + Logger.log_message( + Logger.ERROR, "Failed to pause server recording --> " + str(ex)) + if CALL_RECODING_NOT_FOUND_ERROR_CODE in str(ex): + return web.Response(text=str(ex), status=400) + return web.Response(text=str(ex), status=500) + + async def resume_recording(request): + try: + server_call_id = request.rel_url.query['serverCallId'] + recording_id = request.rel_url.query['recordingId'] + + Logger.log_message( + Logger.INFORMATION, + 'ResumeRecording called with serverCallId --> ' + server_call_id + ' and recordingId --> ' + recording_id) + + if not server_call_id: + return web.Response(text="serverCallId is invalid", status=400) + + if not recording_id: + recording_id = recording_data[server_call_id] + if not recording_id: + return web.Response(text="recordingId is invalid", status=400) + elif server_call_id not in recording_data.keys(): + recording_data[server_call_id] = recording_id + + res = calling_server_client.resume_recording( + recording_id=recording_id) + + Logger.log_message(Logger.INFORMATION, + "ResumeRecording response --> " + str(res)) + return web.Response(text="Ok") + except Exception as ex: + Logger.log_message( + Logger.ERROR, "Failed to resume server recording --> " + str(ex)) + if CALL_RECODING_NOT_FOUND_ERROR_CODE in str(ex): + return web.Response(text=str(ex), status=400) + return web.Response(text=str(ex), status=500) + + async def stop_recording(request): + try: + server_call_id = request.rel_url.query['serverCallId'] + recording_id = request.rel_url.query['recordingId'] + + Logger.log_message( + Logger.INFORMATION, + 'StopRecording called with serverCallId --> ' + server_call_id + ' and recordingId --> ' + recording_id) + + if not server_call_id: + return web.Response(text="serverCallId is invalid", status=400) + + if not recording_id: + recording_id = recording_data[server_call_id] + if not recording_id: + return web.Response(text="recordingId is invalid", status=400) + elif server_call_id not in recording_data.keys(): + recording_data[server_call_id] = recording_id + + res = calling_server_client.stop_recording( + recording_id=recording_id) + + Logger.log_message(Logger.INFORMATION, + "StopRecording response --> " + str(res)) + return web.Response(text="Ok") + except Exception as ex: + Logger.log_message( + Logger.ERROR, "Failed to stop server recording --> " + str(ex)) + if CALL_RECODING_NOT_FOUND_ERROR_CODE in str(ex): + return web.Response(text=str(ex), status=400) + return web.Response(text=str(ex), status=500) + + async def get_recording_state(request): + try: + server_call_id = request.rel_url.query['serverCallId'] + recording_id = request.rel_url.query['recordingId'] + + Logger.log_message(Logger.INFORMATION, + 'GetRecordingState called with serverCallId --> ' + server_call_id + 'and recordingId --> ' + recording_id) + + if not server_call_id: + return web.Response(text="serverCallId is invalid", status=400) + if not recording_id: + return web.Response(text="recordingId is invalid", status=400) + + res = calling_server_client.get_recording_properities( + recording_id=recording_id) + + Logger.log_message(Logger.INFORMATION, + "GetRecordingState response --> " + str(res)) + return web.Response(text=res.recording_state, status=200) + except Exception as ex: + Logger.log_message( + Logger.ERROR, "Failed to get recording status --> " + str(ex)) + if CALL_RECODING_NOT_FOUND_ERROR_CODE in str(ex): + return web.Response(text=str(ex), status=400) + return web.Response(text=str(ex), status=500) + + async def get_recording_file(request): + content = await request.content.read() + post_data = str(content.decode('UTF-8')) + if post_data: + Logger.log_message( + Logger.INFORMATION, 'getRecordingFile called with raw data --> ' + post_data) + json_data = ast.literal_eval(json.dumps(post_data)) + event = EventGridEvent.from_dict(ast.literal_eval(json_data)[0]) + Logger.log_message(Logger.INFORMATION, + "Event type is --> " + str(event.event_type)) + Logger.log_message(Logger.INFORMATION, + "Request data --> " + str(event.data)) + + event_data = event.data + try: + if event.event_type == 'Microsoft.EventGrid.SubscriptionValidationEvent': + try: + subscription_validation_event = event_data + code = subscription_validation_event['validationCode'] + if code: + data = {"validationResponse": code} + Logger.log_message(Logger.INFORMATION, + "Successfully Subscribed EventGrid.ValidationEvent --> " + str(data)) + return web.Response(body=str(data), status=200) + except Exception as ex: + Logger.log_message( + Logger.ERROR, "Failed to Subscribe EventGrid.ValidationEvent --> " + str(ex)) + return web.Response(text=str(ex), status=500) + + if event.event_type == 'Microsoft.Communication.RecordingFileStatusUpdated': + acs_recording_file_status_updated_event_data = event_data + acs_recording_chunk_info_properties = acs_recording_file_status_updated_event_data[ + 'recordingStorageInfo']['recordingChunks'][0] + + Logger.log_message( + Logger.INFORMATION, "acsRecordingChunkInfoProperties response data --> " + str(acs_recording_chunk_info_properties)) + + document_id = acs_recording_chunk_info_properties['documentId'] + content_location = acs_recording_chunk_info_properties['contentLocation'] + metadata_location = acs_recording_chunk_info_properties['metadataLocation'] + + process_metadata_response = CallRecordingController.process_file( + document_id, + metadata_location, + FileFormat.json, + DownloadType.METADATA) + + if process_metadata_response is True: + Logger.log_message( + Logger.INFORMATION, "Processing metadata file completed successfully.") + Logger.log_message( + Logger.INFORMATION, "Start processing recording file -- >") + + process_recording_response = CallRecordingController.process_file( + document_id, + content_location, + CallRecordingController.recFileFormat, + DownloadType.RECORDING) + + if process_recording_response is True: + Logger.log_message( + Logger.INFORMATION, "Processing recording and metadata files completed successfully.") + else: + Logger.log_message( + Logger.INFORMATION, "Processing recording file failed with message --> " + str(process_recording_response)) + + else: + Logger.log_message( + Logger.INFORMATION, "Processing metadata file failed with message --> " + str(process_metadata_response)) + + except Exception as ex: + Logger.log_message( + Logger.ERROR, "Failed to get recording file --> " + str(ex)) + else: + Logger.log_message(Logger.INFORMATION, "Postdata is invalid") + return web.Response(text='Postdata is invalid', status=400) + + def process_file(document_id: str, download_location: str, file_format: str, download_type: str): + global upload_response + Logger.log_message(Logger.INFORMATION, "Start downloading " + + download_type + " file. Download url --> " + download_location) + + try: + stream_downloader = calling_server_client.download( + download_location) + + Logger.log_message( + Logger.INFORMATION, "Uploading {0} file to blob".format(download_type)) + + if stream_downloader is not None: + download_response = stream_downloader.readall() + if download_response is not None: + file_name = "{0}.{1}".format(document_id, file_format) + with open(file_name, 'wb') as rec_file: + try: + rec_file.write(download_response) + rec_file.close() + except Exception as ex: + rec_file.close() + + if download_type == DownloadType.METADATA: + with open(file_name) as f: + deserializedFile = json.load(f) + obj = Root(**deserializedFile) + + format = obj.recordingInfo['format'] if obj.recordingInfo[ + 'format'] in Mapper.rec_format else FileFormat.mp4 + CallRecordingController.recFileFormat = format + + Logger.log_message( + Logger.INFORMATION, "Recording File Format is -- > " + CallRecordingController.recFileFormat) + + upload_response = BlobStorageHelper.upload_file_to_storage( + container_name=container_name, + blob_name=file_name, + blob_connection_string=blob_connection_string) + + if upload_response is True: + Logger.log_message( + Logger.INFORMATION, "File {0} upload to Azure successful".format(file_name)) + else: + Logger.log_message( + Logger.INFORMATION, "Failed to upload ->" + upload_response) + return False + + blob_sas_url = BlobStorageHelper.get_blob_sas_uri( + account_name=blob_storage_account_name, + account_key=blob_storage_account_key, + container_name=container_name, + blob_name=file_name) + Logger.log_message(Logger.INFORMATION, + "blob_url = " + blob_sas_url) + else: + return False + else: + return False + + except Exception as ex: + Logger.log_message(Logger.ERROR, str(ex)) + if ex: + Logger.log_message(Logger.INFORMATION, + "exception request header ----> " + str(ex)) + Logger.log_message( + Logger.INFORMATION, "exception response header ----> " + str(ex)) + return str(ex) + + return True + + def startup(request): + return web.Response(text="App is running.....") + + async def get_blob_sas_uri(request): + blob_name = request.rel_url.query['blob_name'] + blob_sas_token = BlobStorageHelper.get_blob_sas_token( + account_name=blob_storage_account_name, + account_key=blob_storage_account_key, + container_name=container_name, + blob_name=blob_name) + + if blob_sas_token: + blob_uri_template = 'https://{account_name}.blob.core.windows.net/{container_name}/{blob_name}?{blob_sas_token}' + blob_sas_url = blob_uri_template.format( + account_name=blob_storage_account_name, + container_name=container_name, + blob_name=blob_name, + blob_sas_token=blob_sas_token + ) + return web.Response(text=blob_sas_url, status=200) + return web.Response(text="Error occoured in getting blob sas uri") diff --git a/ServerRecording/Controller/__init__.py b/ServerRecording/Controller/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ServerRecording/Dockerfile b/ServerRecording/Dockerfile new file mode 100644 index 0000000..c158776 --- /dev/null +++ b/ServerRecording/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.9 + +RUN mkdir -p /code +WORKDIR /code +ADD . /code +RUN pip install -r requirements.txt +EXPOSE 5000 +CMD ["python", "/code/App.py"] diff --git a/ServerRecording/FileFormats.py b/ServerRecording/FileFormats.py new file mode 100644 index 0000000..dedb94d --- /dev/null +++ b/ServerRecording/FileFormats.py @@ -0,0 +1,17 @@ +class Mapper: + + rec_content = ['audio', 'audiovideo'] + rec_channel = ['mixed', 'unmixed'] + rec_format = ['mp3', 'mp4', 'wav'] + + +class FileFormat: + json = 'json' + mp4 = 'mp4' + mp3 = 'mp3' + wav = 'wav' + + +class DownloadType: + RECORDING = 'RECORDING' + METADATA = 'METADATA' \ No newline at end of file diff --git a/ServerRecording/Logger.py b/ServerRecording/Logger.py new file mode 100644 index 0000000..bcc472e --- /dev/null +++ b/ServerRecording/Logger.py @@ -0,0 +1,11 @@ +import enum + + +class Logger(enum.Enum): + INFORMATION = 1 + ERROR = 2 + + @staticmethod + def log_message(message_type: object, message: str): + log_message = message_type.name + " : " + message + print(log_message) diff --git a/ServerRecording/README.md b/ServerRecording/README.md new file mode 100644 index 0000000..e3ae26b --- /dev/null +++ b/ServerRecording/README.md @@ -0,0 +1,97 @@ +--- +page_type: sample +languages: +- Python +products: +- azure +- azure-communication-services +--- + +# Recording APIs sample + +This is a sample application to show how the Azure Communication Services server calling SDK can be used to build a call recording feature. + +It's a Python based application that connects with Azure Communication Services. + +## Prerequisites + +- Create an Azure account with an active subscription. For details, see [Create an account for free](https://azure.microsoft.com/free/?WT.mc_id=A261C142F) +- [Visual Studio Code](https://code.visualstudio.com/) +- [Python 3.9 ](https://www.python.org/downloads/release/python-390/) (Make sure to install the version that corresponds with your visual studio code instance, 32 vs 64 bit) +- Create an Azure Communication Services resource. For details, see [Create an Azure Communication Resource](https://docs.microsoft.com/azure/communication-services/quickstarts/create-communication-resource). You'll need to record your resource **connection string** for this quickstart. +- An Azure storage account and container, for details, see [Create a storage account](https://docs.microsoft.com/azure/storage/common/storage-account-create?tabs=azure-portal). You'll need to record your storage **connection string** and **container name** for this quickstart. +- Create a webhook and subscribe to the recording events. For details, see [Create webhook](https://docs.microsoft.com/azure/communication-services/quickstarts/voice-video-calling/download-recording-file-sample) + +- [Install Docker](https://docs.docker.com/desktop/windows/install/) + +## Code structure + +- ./ServerRecording/Controllers : Server app core logic for calling the recording APIs using Azure Communication Services server calling SDK +- ./ServerRecording/App.py : Entry point for the server app program logic +- ./ServerRecording/requirement.txt : Contains dependencies for running and deploying the application + +## Before running the sample for the first time + +1. Open an instance of PowerShell, Windows Terminal, Command Prompt or equivalent and navigate to the directory that you'd like to clone the sample to. +2. git clone https://github.com/Azure-Samples/communication-services-python-quickstarts. +3. Once you get the config keys add the keys to the **ServerRecording/config.ini** file found under the Main folder. + - Input your ACS connection string in the variable `Connectionstring` + - Input your storage connection string in the variable `BlobStorageConnectionString` + - Input blob container name for recorded media in the variable `ContainerName` + - Input recording callback url for start recording api in the variable `CallbackUri` + - Input your blob storage account name in the variable `BlobStorageAccountName`, it can be derived from the `BlobStorageConnectionString` + - Input your blob storage account key in the variable `BlobStorageAccountKey`, it can be derived from the `BlobStorageConnectionString` + +## Locally running the sample app + +1. Go to ServerRecording folder and open `App.py` in Visual Studio code. +2. Run `App.py` from the Run > Start debugging. +3. Use postman or any debugging tool and open url - http://0.0.0.0:5000/. + +## Deploying the sample app on Azure + +Follow this to create azure container registry - [Create an Azure container registry using the Azure portal](https://docs.microsoft.com/azure/container-registry/container-registry-get-started-portal) + +Below steps are to create and push docker image to Azure container registry in using Visual studio Code: + +**Note**: All commands are run in root directory of project where we have App.py file. + +1. Login to Azure using : + + az login + +1. Login to the Azure container registry using : + + az acr login --name + +1. Build the docker file to create docker image using : + + docker build -f Dockerfile -t :latest . + +1. Push the docker image to Azure container registry using : + + docker push .azurecr.io/:latest + + Note the digest Id from the terminal after push is complete. + +1. Create web app using docker image generated and pushed in above step, follow this for detail : [Deploy to Azure Web App for Containers](https://docs.microsoft.com/azure/devops/pipelines/apps/cd/deploy-docker-webapp) + + We can use same image name for redeployment, we can see the option to redeploy in the Deployment Center option of App Service in Azure portal. + +1. Check the digest id after push command and compare that with on server, we can see digest Id of App in the Log streams of the App service, they should be same. + + +### Troubleshooting + +1. Solution doesn't build, it throws errors during build + + - Check if the azure SDK is installed. + - Check if all the dependencies are installed as mentioned in requirement.txt + - Check the digest id after push command and compare that with on server, the digest id on server should match with the latest push digest. We can get server digest Id on the log stream section of the App service. + + +**Note**: While you may use http://localhost for local testing, Some of the features will work only after deployment on Azure. + +## Additional Reading + +- [Azure Communication Calling SDK](https://docs.microsoft.com/azure/communication-services/concepts/voice-video-calling/calling-sdk-features) - To learn more about the Calling Web SDK \ No newline at end of file diff --git a/ServerRecording/Root.py b/ServerRecording/Root.py new file mode 100644 index 0000000..d0081eb --- /dev/null +++ b/ServerRecording/Root.py @@ -0,0 +1,72 @@ +from uuid import UUID +from datetime import datetime +from typing import List, Any + + +class Participant: + participantId: str + + def __init__(self, participantId: str) -> None: + self.participantId = participantId + + +class AudioConfiguration: + sampleRate: int + bitRate: int + channels: int + + def __init__(self, sampleRate: int, bitRate: int, channels: int) -> None: + self.sampleRate = sampleRate + self.bitRate = bitRate + self.channels = channels + + +class VideoConfiguration: + longerSideLength: int + shorterSideLength: int + framerate: int + bitRate: int + + def __init__(self, longerSideLength: int, shorterSideLength: int, framerate: int, bitRate: int) -> None: + self.longerSideLength = longerSideLength + self.shorterSideLength = shorterSideLength + self.framerate = framerate + self.bitRate = bitRate + + +class RecordingInfo: + contentType: str + channelType: str + format: str + audioConfiguration: AudioConfiguration + videoConfiguration: VideoConfiguration + + def __init__(self, contentType: str, channelType: str, format: str, audioConfiguration: AudioConfiguration, videoConfiguration: VideoConfiguration) -> None: + self.contentType = contentType + self.channelType = channelType + self.format = format + self.audioConfiguration = audioConfiguration + self.videoConfiguration = videoConfiguration + + +class Root: + resourceId: UUID + callId: UUID + chunkDocumentId: str + chunkIndex: int + chunkStartTime: datetime + chunkDuration: float + pauseResumeIntervals: List[Any] + recordingInfo: RecordingInfo + participants: List[Participant] + + def __init__(self, resourceId: UUID, callId: UUID, chunkDocumentId: str, chunkIndex: int, chunkStartTime: datetime, chunkDuration: float, pauseResumeIntervals: List[Any], recordingInfo: RecordingInfo, participants: List[Participant]) -> None: + self.resourceId = resourceId + self.callId = callId + self.chunkDocumentId = chunkDocumentId + self.chunkIndex = chunkIndex + self.chunkStartTime = chunkStartTime + self.chunkDuration = chunkDuration + self.pauseResumeIntervals = pauseResumeIntervals + self.recordingInfo = recordingInfo + self.participants = participants diff --git a/ServerRecording/config.ini b/ServerRecording/config.ini new file mode 100644 index 0000000..34bb96e --- /dev/null +++ b/ServerRecording/config.ini @@ -0,0 +1,7 @@ +[DEFAULT] +Connectionstring=%Connectionstring% +CallbackUri=%CallbackUri% +BlobStorageConnectionString=%BlobStorageConnectionString% +BlobStorageAccountName=%BlobStorageAccountName% +BlobStorageAccountKey=%BlobStorageAccountKey% +ContainerName=%ContainerName% \ No newline at end of file diff --git a/ServerRecording/requirements.txt b/ServerRecording/requirements.txt new file mode 100644 index 0000000..544da3d --- /dev/null +++ b/ServerRecording/requirements.txt @@ -0,0 +1,4 @@ +aiohttp +azure-storage-blob +azure-eventgrid +azure-communication-callingserver \ No newline at end of file diff --git a/call-automation-Simple-IVR/CallConfiguration.py b/call-automation-Simple-IVR/CallConfiguration.py new file mode 100644 index 0000000..51a8567 --- /dev/null +++ b/call-automation-Simple-IVR/CallConfiguration.py @@ -0,0 +1,15 @@ +from EventHandler.EventAuthHandler import EventAuthHandler + + +class CallConfiguration: + + def __init__(self, connection_string, source_identity, source_phone_number, app_base_url, audio_file_name): + self.connection_string: str = str(connection_string) + self.source_identity: str = str(source_identity) + self.source_phone_number: str = str(source_phone_number) + self.app_base_url: str = str(app_base_url) + self.audio_file_name: str = str(audio_file_name) + eventhandler = EventAuthHandler() + self.app_callback_url: str = app_base_url + \ + "/api/outboundcall/callback?" + eventhandler.get_secret_querystring() + self.audio_file_url: str = app_base_url + "/audio/" + audio_file_name diff --git a/call-automation-Simple-IVR/ConfigurationManager.py b/call-automation-Simple-IVR/ConfigurationManager.py new file mode 100644 index 0000000..4a3483b --- /dev/null +++ b/call-automation-Simple-IVR/ConfigurationManager.py @@ -0,0 +1,23 @@ +import configparser + + +class ConfigurationManager: + __configuration = None + __instance = None + + def __init__(self): + if(self.__configuration == None): + self.__configuration = configparser.ConfigParser() + self.__configuration.read('config.ini') + + @staticmethod + def get_instance(): + if(ConfigurationManager.__instance == None): + ConfigurationManager.__instance = ConfigurationManager() + + return ConfigurationManager.__instance + + def get_app_settings(self, key): + if (key != None): + return self.__configuration.get('default', key) + return None diff --git a/call-automation-Simple-IVR/Logger.py b/call-automation-Simple-IVR/Logger.py new file mode 100644 index 0000000..3014d46 --- /dev/null +++ b/call-automation-Simple-IVR/Logger.py @@ -0,0 +1,12 @@ +import enum + + +class Logger(enum.Enum): + + INFORMATION = 1 + ERROR = 2 + + @staticmethod + def log_message(message_type, message): + log_message = message_type.name + " : " + message + print(log_message) diff --git a/call-automation-Simple-IVR/__init__.py b/call-automation-Simple-IVR/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/call-automation-Simple-IVR/audio/AddParticipant.wav b/call-automation-Simple-IVR/audio/AddParticipant.wav new file mode 100644 index 0000000..7491fbd Binary files /dev/null and b/call-automation-Simple-IVR/audio/AddParticipant.wav differ diff --git a/call-automation-Simple-IVR/audio/InvalidInputAudio.wav b/call-automation-Simple-IVR/audio/InvalidInputAudio.wav new file mode 100644 index 0000000..3dfa656 Binary files /dev/null and b/call-automation-Simple-IVR/audio/InvalidInputAudio.wav differ diff --git a/call-automation-Simple-IVR/audio/RemoveParticipant.wav b/call-automation-Simple-IVR/audio/RemoveParticipant.wav new file mode 100644 index 0000000..9b6f577 Binary files /dev/null and b/call-automation-Simple-IVR/audio/RemoveParticipant.wav differ diff --git a/call-automation-Simple-IVR/audio/TimedoutAudio.wav b/call-automation-Simple-IVR/audio/TimedoutAudio.wav new file mode 100644 index 0000000..fb76eeb Binary files /dev/null and b/call-automation-Simple-IVR/audio/TimedoutAudio.wav differ diff --git a/call-automation-Simple-IVR/audio/agent.wav b/call-automation-Simple-IVR/audio/agent.wav new file mode 100644 index 0000000..522006c Binary files /dev/null and b/call-automation-Simple-IVR/audio/agent.wav differ diff --git a/call-automation-Simple-IVR/audio/customercare.wav b/call-automation-Simple-IVR/audio/customercare.wav new file mode 100644 index 0000000..dc0ac9e Binary files /dev/null and b/call-automation-Simple-IVR/audio/customercare.wav differ diff --git a/call-automation-Simple-IVR/audio/invalid.wav b/call-automation-Simple-IVR/audio/invalid.wav new file mode 100644 index 0000000..04e7db7 Binary files /dev/null and b/call-automation-Simple-IVR/audio/invalid.wav differ diff --git a/call-automation-Simple-IVR/audio/mainmenu.wav b/call-automation-Simple-IVR/audio/mainmenu.wav new file mode 100644 index 0000000..b96672b Binary files /dev/null and b/call-automation-Simple-IVR/audio/mainmenu.wav differ diff --git a/call-automation-Simple-IVR/audio/marketing.wav b/call-automation-Simple-IVR/audio/marketing.wav new file mode 100644 index 0000000..e997811 Binary files /dev/null and b/call-automation-Simple-IVR/audio/marketing.wav differ diff --git a/call-automation-Simple-IVR/audio/sales.wav b/call-automation-Simple-IVR/audio/sales.wav new file mode 100644 index 0000000..dcccfab Binary files /dev/null and b/call-automation-Simple-IVR/audio/sales.wav differ diff --git a/call-automation-Simple-IVR/config.ini b/call-automation-Simple-IVR/config.ini new file mode 100644 index 0000000..ff69980 --- /dev/null +++ b/call-automation-Simple-IVR/config.ini @@ -0,0 +1,27 @@ +# app settings +[default] +Connectionstring=%Connectionstring% +# +ACSAlternatePhoneNumber=%SourcePhone% +# +ParticipantToAdd=%DestinationIdentities% +MaxRetryCount=3 +BaseUri=%NgrokExePath% +MainMenuAudio = /Audio/mainmenu.wav +SalesAudio= /Audio/sales.wav +MarketingAudio= /Audio/marketing.wav +CustomerCareAudio=/Audio/customercare.wav +AddParticipant = /Audio/AddParticipant.wav +AgentAudio= /Audio/AgentAudio.wav +InvalidInputAudio= /Audio/InvalidInputAudio.wav +TimedoutAudio = /Audio/TimedoutAudio.wav +# AddParticipant= /audio/AddParticipant.wav +# RemoveParticipant= /audio/RemoveParticipant.wav + + + #1= Hangup for eveyone after adding participant + #2= Hangup CA after adding participant + #3= Remove addedd participants after adding them +#HangUpScenarios= 1 +AllowedHosts = * diff --git a/call-automation-Simple-IVR/program.py b/call-automation-Simple-IVR/program.py new file mode 100644 index 0000000..a7403a1 --- /dev/null +++ b/call-automation-Simple-IVR/program.py @@ -0,0 +1,65 @@ +import asyncio +import re +import nest_asyncio +import uuid +import azure +from azure.core.messaging import CloudEvent +from azure.communication.identity._shared.models import CommunicationIdentifier,PhoneNumberIdentifier,\ + CommunicationUserIdentifier,CommunicationIdentifierKind +from azure.cognitiveservices.speech import AudioDataStream, SpeechConfig, SpeechSynthesizer, SpeechSynthesisOutputFormat +import json +from aiohttp import web +from Logger import Logger +from ConfigurationManager import ConfigurationManager +from CallConfiguration import CallConfiguration +from azure.communication.identity import CommunicationIdentityClient +from azure.communication.callautomation import CallAutomationClient,CallInvite,\ +CallAutomationEventParser,CallConnected,CallMediaRecognizeOptions,CallMediaRecognizeDtmfOptions,\ +CallConnectionClient,CallDisconnected,PlaySource,FileSource,ParticipantsUpdated,DtmfTone,\ +RecognizeCanceled,RecognizeCompleted,RecognizeFailed,AddParticipantFailed,AddParticipantSucceeded,\ + PlayCompleted,PlayFailed + + +configuration_manager = ConfigurationManager.get_instance() +calling_Automation_client = CallAutomationClient.from_connection_string(configuration_manager.get_app_settings("Connectionstring")) +ngrok_url =configuration_manager.get_app_settings("AppBaseUri") + +user_identity_regex: str = "8:acs:[0-9a-fA-F]{8}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{12}_[0-9a-fA-F]{8}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{12}" +phone_identity_regex: str = "^\\+\\d{10,14}$" + +def get_identifier_kind(self, participantnumber: str): + # checks the identity type returns as string + if (re.search(self.user_identity_regex, participantnumber)): + return CommunicationIdentifierKind.COMMUNICATION_USER + elif (re.search(self.phone_identity_regex, participantnumber)): + return CommunicationIdentifierKind.PHONE_NUMBER + else: + return CommunicationIdentifierKind.UNKNOWN + +class InboundCallAutomationController(): + + recFileFormat = '' + + def __init__(self): + app = web.Application() + app.add_routes([web.get("/api/incomingCall", self.run_sample)]) + app.add_routes([web.get("/startRecordingWithOptions", self.load_file)]) + app.add_routes([web.get("/api/calls/{contextId}", self.start_callBack)]) + web.run_app(app, port=58963) + + async def run_sample(request): + try: + + content = await request.content.read() + event = CallAutomationEventParser.parse(content) + if event._class=="": + Logger.log_message( + Logger.ERROR, "Failed to start server recording --> " ) + + + except Exception as ex: + Logger.log_message( + Logger.ERROR, "Failed to start server recording --> " + str(ex)) + + + \ No newline at end of file diff --git a/call-automation-Simple-IVR/readme.md b/call-automation-Simple-IVR/readme.md new file mode 100644 index 0000000..286ccf5 --- /dev/null +++ b/call-automation-Simple-IVR/readme.md @@ -0,0 +1,52 @@ +--- +page_type: sample +languages: +- python +products: +- azure +- azure-communication-services +--- + +# Outbound Reminder Call Sample + +This sample application shows how the Azure Communication Services Server, Calling package can be used to build IVR related solutions. This sample makes an outbound call to a phone number or a communication identifier and plays an audio message. If the callee presses 1 (tone1), to reschedule an appointment, then the application invites a new participant and then leaves the call. If the callee presses any other key then the application ends the call. This sample application is also capable of making multiple concurrent outbound calls. +The application is a console based application build using Python 3.9. + +## Getting started + +### Prerequisites + +- Create an Azure account with an active subscription. For details, see [Create an account for free](https://azure.microsoft.com/free/) +- [Python](https://www.python.org/downloads/) 3.9 and above +- Create an Azure Communication Services resource. For details, see [Create an Azure Communication Resource](https://docs.microsoft.com/azure/communication-services/quickstarts/create-communication-resource). You'll need to record your resource **connection string** for this sample. +- Get a phone number for your new Azure Communication Services resource. For details, see [Get a phone number](https://docs.microsoft.com/azure/communication-services/quickstarts/telephony-sms/get-phone-number?pivots=platform-azp) +- Download and install [Ngrok](https://www.ngrok.com/download). As the sample is run locally, Ngrok will enable the receiving of all the events. +- Download and install [Visual C++](https://support.microsoft.com/en-us/topic/the-latest-supported-visual-c-downloads-2647da03-1eea-4433-9aff-95f26a218cc0) +- (Optional) Create Azure Speech resource for generating custom message to be played by application. Follow [here](https://docs.microsoft.com/azure/cognitive-services/speech-service/overview#try-the-speech-service-for-free) to create the resource. + +> Note: the samples make use of the Microsoft Cognitive Services Speech SDK. By downloading the Microsoft Cognitive Services Speech SDK, you acknowledge its license, see [Speech SDK license agreement](https://aka.ms/csspeech/license201809). + +### Configuring application + +- Open the config.ini file to configure the following settings + + - Connection String: Azure Communication Service resource's connection string. + - Source Phone: Phone number associated with the Azure Communication Service resource. + - DestinationIdentities: Multiple sets of outbound target and Transfer target. These sets are seperated by a semi-colon, and outbound target and Transfer target in a each set are seperated by a coma. + + Format: "OutboundTarget1(PhoneNumber),TransferTarget1(PhoneNumber/MRI);OutboundTarget2(PhoneNumber),TransferTarget2(PhoneNumber/MRI);OutboundTarget3(PhoneNumber),TransferTarget3(PhoneNumber/MRI)". + + For e.g. "+1425XXXAAAA,8:acs:ab12b0ea-85ea-4f83-b0b6-84d90209c7c4_00000009-bce0-da09-54b7-xxxxxxxxxxxx;+1425XXXBBBB,+1425XXXCCCC" + + - NgrokExePath: Folder path where ngrok.exe is insalled/saved. + - SecretPlaceholder: Secret/Password that would be part of callback and will be use to validate incoming requests. + - CognitiveServiceKey: (Optional) Cognitive service key used for generating custom message + - CognitiveServiceRegion: (Optional) Region associated with cognitive service + - CustomMessage: (Optional) Text for the custom message to be converted to speech. + +### Run the Application + +- Add azure communication callingserver's wheel file path in requirement.txt +- Navigate to the directory containing the requirements.txt file and use the following commands for installing all the dependencies and for running the application respectively: + - pip install -r requirements.txt + - python program.py diff --git a/call-automation-Simple-IVR/requirements.txt b/call-automation-Simple-IVR/requirements.txt new file mode 100644 index 0000000..98a246c --- /dev/null +++ b/call-automation-Simple-IVR/requirements.txt @@ -0,0 +1,31 @@ +aiohttp==3.7.4.post0 +async-timeout==3.0.1 +attrs==21.2.0 +azure-cognitiveservices-speech==1.27.0 +azure-common==1.1.27 +azure-communication-callautomation @file:///C:/Users/v-moirf/pyenv311/azure_communication_callautomation-1.0.0a20230413001-py3-none-any.whl +azure-communication-chat==1.1.0 +azure-communication-identity==1.3.1 +azure-core==1.26.4 +azure-nspkg==3.0.2 +azure-storage==0.36.0 +certifi==2021.5.30 +cffi==1.15.1 +chardet==4.0.0 +charset-normalizer==2.0.4 +cryptography==3.4.8 +idna==3.2 +isodate==0.6.0 +msrest==0.7.1 +multidict==6.0.4 +nest-asyncio==1.5.1 +oauthlib==3.1.1 +psutil==5.8.0 +pycparser==2.20 +python-dateutil==2.8.2 +requests==2.26.0 +requests-oauthlib==1.3.0 +six==1.16.0 +typing-extensions==4.3.0 +urllib3==1.26.6 +yarl==1.8.2 \ No newline at end of file