diff --git a/OutboundCallReminder/CallConfiguration.py b/OutboundCallReminder/CallConfiguration.py
new file mode 100644
index 0000000..51a8567
--- /dev/null
+++ b/OutboundCallReminder/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/OutboundCallReminder/CommunicationIdentifierKind.py b/OutboundCallReminder/CommunicationIdentifierKind.py
new file mode 100644
index 0000000..571b625
--- /dev/null
+++ b/OutboundCallReminder/CommunicationIdentifierKind.py
@@ -0,0 +1,8 @@
+import enum
+
+
+class CommunicationIdentifierKind(enum.Enum):
+
+ USER_IDENTITY = 1
+ PHONE_IDENTITY = 2
+ UNKNOWN_IDENTITY = 3
diff --git a/OutboundCallReminder/ConfigurationManager.py b/OutboundCallReminder/ConfigurationManager.py
new file mode 100644
index 0000000..4a3483b
--- /dev/null
+++ b/OutboundCallReminder/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/OutboundCallReminder/Controller/OutboundCallController.py b/OutboundCallReminder/Controller/OutboundCallController.py
new file mode 100644
index 0000000..9bb1514
--- /dev/null
+++ b/OutboundCallReminder/Controller/OutboundCallController.py
@@ -0,0 +1,30 @@
+from aiohttp import web
+from EventHandler.EventAuthHandler import EventAuthHandler
+from EventHandler.EventDispatcher import EventDispatcher
+
+
+class OutboundCallController():
+
+ app = web.Application()
+
+ def __init__(self):
+ self.app.add_routes(
+ [web.post('/api/outboundcall/callback', self.on_incoming_request_async)])
+ self.app.add_routes([web.get("/audio/{file_name}", self.load_file)])
+ web.run_app(self.app, port=9007)
+
+ async def on_incoming_request_async(self, request):
+ param = request.rel_url.query
+ content = await request.content.read()
+
+ eventhandler = EventAuthHandler()
+ if (param.get('secret') and eventhandler.authorize(param['secret'])):
+ eventDispatcher: EventDispatcher = EventDispatcher.get_instance()
+ eventDispatcher.process_notification(str(content.decode('UTF-8')))
+
+ 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
diff --git a/OutboundCallReminder/Controller/__init__.py b/OutboundCallReminder/Controller/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/OutboundCallReminder/EventHandler/EventAuthHandler.py b/OutboundCallReminder/EventHandler/EventAuthHandler.py
new file mode 100644
index 0000000..61d00d4
--- /dev/null
+++ b/OutboundCallReminder/EventHandler/EventAuthHandler.py
@@ -0,0 +1,22 @@
+from ConfigurationManager import ConfigurationManager
+from Logger import Logger
+
+
+class EventAuthHandler:
+
+ secret_value = None
+
+ def __init__(self):
+ configuration = ConfigurationManager.get_instance()
+ self.secret_value = configuration.get_app_settings("SecretPlaceholder")
+
+ if (self.secret_value == None or self.secret_value == ''):
+ Logger.log_message(Logger.INFORMATION, "SecretPlaceholder is null")
+ self.secret_value = "h3llowW0rld"
+
+ def authorize(self, requestSecretValue):
+ return ((requestSecretValue != None) and (requestSecretValue == self.secret_value))
+
+ def get_secret_querystring(self):
+ secretKey = "secret"
+ return (secretKey + "=" + self.secret_value)
diff --git a/OutboundCallReminder/EventHandler/EventDispatcher.py b/OutboundCallReminder/EventHandler/EventDispatcher.py
new file mode 100644
index 0000000..d555bb6
--- /dev/null
+++ b/OutboundCallReminder/EventHandler/EventDispatcher.py
@@ -0,0 +1,101 @@
+from Logger import Logger
+from threading import Lock
+import threading
+import json
+from azure.core.messaging import CloudEvent
+from azure.communication.callingserver import CallingServerEventType, \
+ CallConnectionStateChangedEvent, ToneReceivedEvent, \
+ PlayAudioResultEvent, AddParticipantResultEvent
+
+
+class EventDispatcher:
+ __instance = None
+ notification_callbacks: dict = None
+ subscription_lock = None
+
+ def __init__(self):
+ self.notification_callbacks = dict()
+ self.subscription_lock = Lock()
+
+ @staticmethod
+ def get_instance():
+ if EventDispatcher.__instance is None:
+ EventDispatcher.__instance = EventDispatcher()
+
+ return EventDispatcher.__instance
+
+ def subscribe(self, event_type: str, event_key: str, notification_callback):
+ self.subscription_lock.acquire
+ event_id: str = self.build_event_key(event_type, event_key)
+ self.notification_callbacks[event_id] = notification_callback
+ self.subscription_lock.release
+
+ def unsubscribe(self, event_type: str, event_key: str):
+ self.subscription_lock.acquire
+ event_id: str = self.build_event_key(event_type, event_key)
+ del self.notification_callbacks[event_id]
+ self.subscription_lock.release
+
+ def build_event_key(self, event_type: str, event_key: str):
+ return event_type + "-" + event_key
+
+ def process_notification(self, request: str):
+ call_event = self.extract_event(request)
+ if call_event is not None:
+ self.subscription_lock.acquire
+ notification_callback = self.notification_callbacks.get(
+ self.get_event_key(call_event))
+ if (notification_callback != None):
+ threading.Thread(target=notification_callback,
+ args=(call_event,)).start()
+
+ def get_event_key(self, call_event_base):
+ if type(call_event_base) == CallConnectionStateChangedEvent:
+ call_leg_id = call_event_base.call_connection_id
+ key = self.build_event_key(
+ CallingServerEventType.CALL_CONNECTION_STATE_CHANGED_EVENT, call_leg_id)
+ return key
+ elif type(call_event_base) == ToneReceivedEvent:
+ call_leg_id = call_event_base.call_connection_id
+ key = self.build_event_key(
+ CallingServerEventType.TONE_RECEIVED_EVENT, call_leg_id)
+ return key
+ elif type(call_event_base) == PlayAudioResultEvent:
+ operation_context = call_event_base.operation_context
+ key = self.build_event_key(
+ CallingServerEventType.PLAY_AUDIO_RESULT_EVENT, operation_context)
+ return key
+ elif type(call_event_base) == AddParticipantResultEvent:
+ operation_context = call_event_base.operation_context
+ key = self.build_event_key(
+ CallingServerEventType.ADD_PARTICIPANT_RESULT_EVENT, operation_context)
+ return key
+ return None
+
+ def extract_event(self, content: str):
+ try:
+ event = CloudEvent.from_dict(json.loads(content)[0])
+ if event.type == CallingServerEventType.CALL_CONNECTION_STATE_CHANGED_EVENT:
+ call_connection_state_changed_event = CallConnectionStateChangedEvent.deserialize(
+ event.data)
+ return call_connection_state_changed_event
+
+ if event.type == CallingServerEventType.PLAY_AUDIO_RESULT_EVENT:
+ play_audio_result_event = PlayAudioResultEvent.deserialize(
+ event.data)
+ return play_audio_result_event
+
+ if event.type == CallingServerEventType.ADD_PARTICIPANT_RESULT_EVENT:
+ add_participant_result_event = AddParticipantResultEvent.deserialize(
+ event.data)
+ return add_participant_result_event
+
+ if event.type == CallingServerEventType.TONE_RECEIVED_EVENT:
+ tone_received_event = ToneReceivedEvent.deserialize(event.data)
+ return tone_received_event
+
+ except Exception as ex:
+ Logger.log_message(
+ Logger.ERROR, "Failed to parse request content Exception: " + str(ex))
+
+ return None
diff --git a/OutboundCallReminder/EventHandler/__init__.py b/OutboundCallReminder/EventHandler/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/OutboundCallReminder/Logger.py b/OutboundCallReminder/Logger.py
new file mode 100644
index 0000000..3014d46
--- /dev/null
+++ b/OutboundCallReminder/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/OutboundCallReminder/Ngrok/NgrokConnector.py b/OutboundCallReminder/Ngrok/NgrokConnector.py
new file mode 100644
index 0000000..7e1ba15
--- /dev/null
+++ b/OutboundCallReminder/Ngrok/NgrokConnector.py
@@ -0,0 +1,13 @@
+import requests
+import json
+
+
+class NgrokConnector:
+
+ def __init__(self):
+ self.ngrok_tunnel_url = "http://127.0.0.1:4040/api/tunnels"
+
+ def get_all_tunnels(self):
+ tunnel_url = requests.get(self.ngrok_tunnel_url).text
+ tunnel_list = json.loads(tunnel_url)
+ return tunnel_list
diff --git a/OutboundCallReminder/Ngrok/NgrokService.py b/OutboundCallReminder/Ngrok/NgrokService.py
new file mode 100644
index 0000000..c49a951
--- /dev/null
+++ b/OutboundCallReminder/Ngrok/NgrokService.py
@@ -0,0 +1,82 @@
+from time import sleep
+from Ngrok.NgrokConnector import NgrokConnector
+import os
+import subprocess
+import psutil
+import signal
+from pathlib import Path
+
+
+class NgrokService:
+ # The NGROK process
+ __ngrokProcess = None # Process
+ # NgrokConnector connector;
+ __connector = None # NgrokConnector
+
+ def __init__(self, ngrokPath, authToken):
+ self.__connector = NgrokConnector()
+ self.ensure_ngrok_not_running()
+ self.create_ngrok_process(ngrokPath, authToken)
+
+ #
+ # Ensures that NGROK is not running.
+ #
+
+ def ensure_ngrok_not_running(self):
+ process_name = "ngrok.exe"
+
+ for proc in psutil.process_iter():
+ try:
+ # Check if process name contains the given name string.
+ if process_name.lower() in proc.name().lower():
+ raise(
+ "Looks like NGROK is still running. Please kill it before running the provider again.")
+
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
+ pass
+
+ #
+ # Kill ngrok.exe process
+ #
+
+ def dispose(self):
+ out, err = self.__ngrokProcess.communicate()
+ for line in out.splitlines():
+ if 'ngrok.exe' in line:
+ pid = int(line.split(None, 1)[0])
+ os.kill(pid, signal.SIGKILL)
+
+ #
+ # Creates the NGROK process.
+ #
+
+ def create_ngrok_process(self, ngrokPath, authToken):
+ auth_token_args = ""
+ if (authToken and len(authToken)):
+ auth_token_args = " --authtoken " + authToken
+
+ executable = str(Path(ngrokPath, "ngrok.exe"))
+ os.chmod(executable, 0o777)
+ self.__ngrokProcess = subprocess.Popen(
+ [executable, "http", "http://localhost:9007/", "-host-header", "localhost:9007"])
+
+ #
+ # Get Ngrok URL
+ #
+
+ def get_ngrok_url(self):
+ try:
+ totalAttempts = 4
+ while(totalAttempts > 0):
+ # Wait for fetching the ngrok url as ngrok process might not be started yet.
+ sleep(2)
+ tunnels = self.__connector.get_all_tunnels()
+ if (tunnels and len(tunnels['tunnels'])):
+ # Do the parsing of the get
+ ngrok_url = tunnels['tunnels'][0]['public_url']
+ return ngrok_url
+
+ totalAttempts = totalAttempts - 1
+
+ except Exception as ex:
+ raise Exception("Failed to retrieve ngrok url --> " + str(ex))
diff --git a/OutboundCallReminder/OutboundCallReminder.py b/OutboundCallReminder/OutboundCallReminder.py
new file mode 100644
index 0000000..ac819b4
--- /dev/null
+++ b/OutboundCallReminder/OutboundCallReminder.py
@@ -0,0 +1,341 @@
+from asyncio.tasks import FIRST_COMPLETED
+import re
+import traceback
+import uuid
+import asyncio
+from azure.communication.identity._shared.models import CommunicationIdentifier
+from CallConfiguration import CallConfiguration
+from ConfigurationManager import ConfigurationManager
+from Logger import Logger
+from CommunicationIdentifierKind import CommunicationIdentifierKind
+from EventHandler.EventDispatcher import EventDispatcher
+from azure.communication.identity import CommunicationUserIdentifier
+from azure.communication.chat import PhoneNumberIdentifier
+from azure.communication.callingserver import CallingServerClient, \
+ CallConnection, CallConnectionStateChangedEvent, ToneReceivedEvent, \
+ ToneInfo, PlayAudioResultEvent, AddParticipantResultEvent, CallMediaType, \
+ CallingEventSubscriptionType, CreateCallOptions, CallConnectionState, \
+ CallingOperationStatus, ToneValue, PlayAudioOptions, CallingServerEventType, \
+ PlayAudioResult, AddParticipantResult
+
+PLAY_AUDIO_AWAIT_TIMER = 30
+ADD_PARTICIPANT_AWAIT_TIMER = 60
+
+
+class OutboundCallReminder:
+ call_configuration: CallConfiguration = None
+ calling_server_client: CallingServerClient = None
+ call_connection: CallConnection = None
+
+ call_connected_task: asyncio.Future = None
+ play_audio_completed_task: asyncio.Future = None
+ call_terminated_task: asyncio.Future = None
+ tone_received_complete_task: asyncio.Future = None
+ add_participant_complete_task: asyncio.Future = None
+ max_retry_attempt_count: int = 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 __init__(self, call_configuration):
+
+ self.call_configuration = call_configuration
+ self.calling_server_client = CallingServerClient.from_connection_string(
+ self.call_configuration.connection_string)
+ self.max_retry_attempt_count: int = int(
+ ConfigurationManager.get_instance().get_app_settings("MaxRetryCount"))
+
+ async def report(self, target_phone_number, participant):
+
+ try:
+ await self.create_call_async(target_phone_number)
+
+ self.register_to_dtmf_result_event(
+ self.call_connection.call_connection_id)
+
+ await self.play_audio_async()
+ play_audio_completed = await self.play_audio_completed_task
+
+ if (not play_audio_completed):
+ self.hang_up_async()
+ else:
+ try:
+ tone_received_complete = await asyncio.wait_for(self.tone_received_complete_task, timeout=PLAY_AUDIO_AWAIT_TIMER)
+ except asyncio.TimeoutError as ex:
+ Logger.log_message(
+ Logger.INFORMATION, "No response from user in 30 sec, initiating hangup")
+ else:
+ if (tone_received_complete):
+ Logger.log_message(Logger.INFORMATION, "Initiating add participant from number --> " +
+ target_phone_number + " and participant identifier is -- > " + participant)
+
+ self.add_participant(participant)
+ add_participant_completed = None
+ try:
+ add_participant_completed = await asyncio.wait_for(self.add_participant_complete_task, timeout=ADD_PARTICIPANT_AWAIT_TIMER)
+ except asyncio.TimeoutError as ex:
+ Logger.log_message(
+ Logger.INFORMATION, "Add participant failed with timeout -- > " + str(ex))
+ finally:
+ if (not add_participant_completed):
+ await asyncio.create_task(self.retry_add_participant_async(participant))
+
+ self.hang_up_async()
+
+ # Wait for the call to terminate
+ await self.call_terminated_task
+
+ except Exception as ex:
+ Logger.log_message(
+ Logger.ERROR, "Call ended unexpectedly, reason -- > " + str(ex))
+ print(traceback.format_exc())
+
+ async def create_call_async(self, target_phone_number):
+ try:
+ source = CommunicationUserIdentifier(
+ self.call_configuration.source_identity)
+ targets = [PhoneNumberIdentifier(target_phone_number)]
+
+ call_modality = [CallMediaType.AUDIO]
+ event_subscription_type = [
+ CallingEventSubscriptionType.PARTICIPANTS_UPDATED, CallingEventSubscriptionType.TONE_RECEIVED]
+
+ options: CreateCallOptions = CreateCallOptions(
+ callback_uri=self.call_configuration.app_callback_url, requested_media_types=call_modality, requested_call_events=event_subscription_type)
+ options.alternate_Caller_Id = PhoneNumberIdentifier(
+ self.call_configuration.source_phone_number)
+ options.subject = None
+
+ Logger.log_message(Logger.INFORMATION,
+ "Performing CreateCall operation")
+
+ self.call_connection = self.calling_server_client.create_call_connection(
+ source, targets, options)
+
+ Logger.log_message(
+ Logger.INFORMATION, "Call initiated with Call Leg id -- >" + self.call_connection.call_connection_id)
+
+ self.register_to_callstate_change_event(
+ self.call_connection.call_connection_id)
+
+ await self.call_connected_task
+
+ except Exception as ex:
+ Logger.log_message(
+ Logger.ERROR, "Failure occured while creating/establishing the call. Exception -- >" + str(ex))
+
+ def register_to_callstate_change_event(self, call_leg_id):
+ self.call_terminated_task = asyncio.Future()
+ self.call_connected_task = asyncio.Future()
+
+ # Set the callback method
+ def call_state_change_notificaiton(call_event):
+ try:
+ call_state_changes: CallConnectionStateChangedEvent = call_event
+ Logger.log_message(
+ Logger.INFORMATION, "Call State changed to -- > " + call_state_changes.call_connection_state)
+
+ if (call_state_changes.call_connection_state == CallConnectionState.CONNECTED):
+ Logger.log_message(Logger.INFORMATION,
+ "Call State successfully connected")
+ self.call_connected_task.set_result(True)
+
+ elif (call_state_changes.call_connection_state == CallConnectionState.DISCONNECTED):
+ EventDispatcher.get_instance().unsubscribe(
+ CallingServerEventType.CALL_CONNECTION_STATE_CHANGED_EVENT, call_leg_id)
+ self.call_terminated_task.set_result(True)
+
+ except asyncio.InvalidStateError:
+ pass
+
+ # Subscribe to the event
+ EventDispatcher.get_instance().subscribe(CallingServerEventType.CALL_CONNECTION_STATE_CHANGED_EVENT,
+ call_leg_id, call_state_change_notificaiton)
+
+ def register_to_dtmf_result_event(self, call_leg_id):
+ self.tone_received_complete_task = asyncio.Future()
+
+ def dtmf_received_event(call_event):
+ tone_received_event: ToneReceivedEvent = call_event
+ tone_info: ToneInfo = tone_received_event.tone_info
+
+ Logger.log_message(Logger.INFORMATION,
+ "Tone received -- > : " + str(tone_info.tone))
+
+ if (tone_info.tone == ToneValue.TONE1):
+ self.tone_received_complete_task.set_result(True)
+ else:
+ self.tone_received_complete_task.set_result(False)
+
+ EventDispatcher.get_instance().unsubscribe(
+ CallingServerEventType.TONE_RECEIVED_EVENT, call_leg_id)
+ # cancel playing audio
+ self.cancel_media_processing()
+
+ # Subscribe to event
+ EventDispatcher.get_instance().subscribe(
+ CallingServerEventType.TONE_RECEIVED_EVENT, call_leg_id, dtmf_received_event)
+
+ def cancel_media_processing(self):
+ Logger.log_message(
+ Logger.INFORMATION, "Performing cancel media processing operation to stop playing audio")
+
+ self.call_connection.cancel_all_media_operations()
+
+ async def play_audio_async(self):
+ try:
+ self.play_audio_completed_task = asyncio.Future()
+ # Preparing data for request
+ loop = True
+ audio_file_uri = self.call_configuration.audio_file_url
+ operation_context = str(uuid.uuid4())
+ audio_file_id = str(uuid.uuid4())
+ callbackuri = self.call_configuration.app_callback_url
+
+ play_audio_options: PlayAudioOptions = PlayAudioOptions(loop=loop,
+ operation_context=operation_context,
+ audio_file_id=audio_file_id,
+ callback_uri=callbackuri)
+
+ Logger.log_message(Logger.INFORMATION,
+ "Performing PlayAudio operation")
+ play_audio_response: PlayAudioResult = self.call_connection.play_audio(audio_file_uri=audio_file_uri,
+ play_audio_options=play_audio_options)
+
+ Logger.log_message(Logger.INFORMATION, "playAudioWithResponse -- > " + str(play_audio_response) +
+ ", Id: " + play_audio_response.operation_id + ", OperationContext: " + play_audio_response.operation_context + ", OperationStatus: " +
+ play_audio_response.status)
+
+ if (play_audio_response.status == CallingOperationStatus.RUNNING):
+ Logger.log_message(
+ Logger.INFORMATION, "Play Audio state -- > " + str(CallingOperationStatus.RUNNING))
+
+ # listen to play audio events
+ self.register_to_play_audio_result_event(
+ play_audio_response.operation_context)
+
+ tasks = []
+ tasks.append(self.play_audio_completed_task)
+ tasks.append(asyncio.create_task(
+ asyncio.sleep(PLAY_AUDIO_AWAIT_TIMER)))
+
+ await asyncio.wait(tasks, return_when=FIRST_COMPLETED)
+
+ if(not self.play_audio_completed_task.done()):
+ Logger.log_message(
+ Logger.INFORMATION, "No response from user in 30 sec, initiating hangup")
+ self.play_audio_completed_task.set_result(False)
+ self.tone_received_complete_task.set_result(False)
+
+ except Exception as ex:
+ if (self.play_audio_completed_task.cancelled()):
+ Logger.log_message(Logger.INFORMATION,
+ "Play audio operation cancelled")
+ else:
+ Logger.log_message(
+ Logger.INFORMATION, "Failure occured while playing audio on the call. Exception: " + str(ex))
+
+ def hang_up_async(self):
+ Logger.log_message(Logger.INFORMATION, "Performing Hangup operation")
+
+ self.call_connection.hang_up()
+
+ def register_to_play_audio_result_event(self, operation_context):
+ def play_prompt_response_notification(call_event):
+ play_audio_result_event: PlayAudioResultEvent = call_event
+ Logger.log_message(
+ Logger.INFORMATION, "Play audio status -- > " + str(play_audio_result_event.status))
+
+ if (play_audio_result_event.status == CallingOperationStatus.COMPLETED):
+ EventDispatcher.get_instance().unsubscribe(
+ CallingServerEventType.PLAY_AUDIO_RESULT_EVENT, operation_context)
+ self.play_audio_completed_task.set_result(True)
+ elif (play_audio_result_event.status == CallingOperationStatus.FAILED):
+ self.play_audio_completed_task.set_result(False)
+
+ # Subscribe to event
+ EventDispatcher.get_instance().subscribe(CallingServerEventType.PLAY_AUDIO_RESULT_EVENT,
+ operation_context, play_prompt_response_notification)
+
+ async def retry_add_participant_async(self, addedParticipant):
+ retry_attempt_count: int = 1
+ while (retry_attempt_count <= self.max_retry_attempt_count):
+ Logger.log_message(Logger.INFORMATION, "Retrying add participant attempt -- > " +
+ str(retry_attempt_count) + " is in progress")
+ self.add_participant(addedParticipant)
+
+ add_participant_result = None
+ try:
+ add_participant_result = await asyncio.wait_for(self.add_participant_complete_task, timeout=ADD_PARTICIPANT_AWAIT_TIMER)
+ except asyncio.TimeoutError as ex:
+ Logger.log_message(
+ Logger.INFORMATION, "Retry add participant failed with timeout -- > " + str(retry_attempt_count) + str(ex))
+ finally:
+ if (add_participant_result):
+ return
+ else:
+ Logger.log_message(
+ Logger.INFORMATION, "Retry add participant attempt -- > " + str(retry_attempt_count) + " has failed")
+ retry_attempt_count = retry_attempt_count + 1
+
+ def add_participant(self, addedParticipant):
+ identifier_kind: CommunicationIdentifierKind = self.get_identifier_kind(
+ addedParticipant)
+
+ if (identifier_kind == CommunicationIdentifierKind.UNKNOWN_IDENTITY):
+ Logger.log_message(
+ Logger.INFORMATION, "Unknown identity provided. Enter valid phone number or communication user id")
+ return True
+ else:
+ participant: CommunicationIdentifier = None
+ operation_context = str(uuid.uuid4())
+
+ self.register_to_add_participants_result_event(operation_context)
+
+ if (identifier_kind == CommunicationIdentifierKind.USER_IDENTITY):
+ participant = CommunicationUserIdentifier(id=addedParticipant)
+
+ elif (identifier_kind == CommunicationIdentifierKind.PHONE_IDENTITY):
+ participant = PhoneNumberIdentifier(value=addedParticipant)
+
+ alternate_caller_id = PhoneNumberIdentifier(
+ value=str(ConfigurationManager.get_instance().get_app_settings("SourcePhone")))
+
+ add_participant_response: AddParticipantResult = self.call_connection.add_participant(participant=participant,
+ alternate_caller_id=alternate_caller_id, operation_context=operation_context)
+ Logger.log_message(
+ Logger.INFORMATION, "addParticipantWithResponse -- > " + add_participant_response.participant_id)
+
+ def register_to_add_participants_result_event(self, operation_context):
+ if(self.add_participant_complete_task):
+ self.add_participant_complete_task = None
+
+ self.add_participant_complete_task = asyncio.Future()
+
+ def add_participant_received_event(call_event):
+ add_participants_updated_event: AddParticipantResultEvent = call_event
+ operation_status: CallingOperationStatus = add_participants_updated_event.status
+ if (operation_status == CallingOperationStatus.COMPLETED):
+ Logger.log_message(
+ Logger.INFORMATION, "Add participant status -- > " + operation_status)
+ self.add_participant_complete_task.set_result(True)
+ elif(operation_status == CallingOperationStatus.FAILED):
+ Logger.log_message(
+ Logger.INFORMATION, "Add participant status -- > " + operation_status)
+ self.add_participant_complete_task.set_result(False)
+
+ EventDispatcher.get_instance().unsubscribe(
+ CallingServerEventType.ADD_PARTICIPANT_RESULT_EVENT, operation_context)
+
+ # Subscribe to event
+ EventDispatcher.get_instance().subscribe(CallingServerEventType.ADD_PARTICIPANT_RESULT_EVENT,
+ operation_context, add_participant_received_event)
+
+ def get_identifier_kind(self, participantnumber: str):
+ # checks the identity type returns as string
+ if(re.search(self.user_identity_regex, participantnumber)):
+ return CommunicationIdentifierKind.USER_IDENTITY
+ elif(re.search(self.phone_identity_regex, participantnumber)):
+ return CommunicationIdentifierKind.PHONE_IDENTITY
+ else:
+ return CommunicationIdentifierKind.UNKNOWN_IDENTITY
diff --git a/OutboundCallReminder/__init__.py b/OutboundCallReminder/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/OutboundCallReminder/audio/custom-message.wav b/OutboundCallReminder/audio/custom-message.wav
new file mode 100644
index 0000000..98c2726
Binary files /dev/null and b/OutboundCallReminder/audio/custom-message.wav differ
diff --git a/OutboundCallReminder/audio/sample-message.wav b/OutboundCallReminder/audio/sample-message.wav
new file mode 100644
index 0000000..98c2726
Binary files /dev/null and b/OutboundCallReminder/audio/sample-message.wav differ
diff --git a/OutboundCallReminder/config.ini b/OutboundCallReminder/config.ini
new file mode 100644
index 0000000..3af0171
--- /dev/null
+++ b/OutboundCallReminder/config.ini
@@ -0,0 +1,21 @@
+# app settings
+[default]
+#1. Configurations related to Communication Service resource
+Connectionstring=%Connectionstring%
+#
+SourcePhone=%SourcePhone%
+#
+DestinationIdentities=%DestinationIdentities%
+MaxRetryCount=2
+
+# 2. Configurations related to environment
+NgrokExePath=%NgrokExePath%
+SecretPlaceholder=h3llowW0rld
+
+#
+CognitiveServiceKey=
+#
+CognitiveServiceRegion=
+#
+CustomMessage=Hello this is a reminder call. If you would like to speak with a representative Press 1 or 2 if you want to hang up.
diff --git a/OutboundCallReminder/program.py b/OutboundCallReminder/program.py
new file mode 100644
index 0000000..f7349cb
--- /dev/null
+++ b/OutboundCallReminder/program.py
@@ -0,0 +1,175 @@
+import asyncio
+import nest_asyncio
+from azure.communication.identity._shared.models import CommunicationIdentifier, CommunicationUserIdentifier
+from Controller.OutboundCallController import OutboundCallController
+from Logger import Logger
+from ConfigurationManager import ConfigurationManager
+from CallConfiguration import CallConfiguration
+from Ngrok.NgrokService import NgrokService
+from azure.communication.identity import CommunicationIdentityClient
+from azure.cognitiveservices.speech import AudioDataStream, SpeechConfig, SpeechSynthesizer, SpeechSynthesisOutputFormat
+from OutboundCallReminder import OutboundCallReminder
+
+
+class Program():
+
+ configuration_manager = None
+ __ngrok_service = None
+ url = "http://localhost:9007"
+
+ def __init__(self):
+ Logger.log_message(Logger.INFORMATION, "Starting ACS Sample App ")
+ # Get configuration properties
+ self.configuration_manager = ConfigurationManager.get_instance()
+
+ async def program(self):
+ # Start Ngrok service
+ ngrok_url = self.start_ngrok_service()
+
+ try:
+ if (ngrok_url and len(ngrok_url)):
+ Logger.log_message(Logger.INFORMATION,
+ "Server started at -- > " + self.url)
+
+ run_sample = asyncio.create_task(self.run_sample(ngrok_url))
+
+ loop = asyncio.get_event_loop()
+ loop.run_until_complete(OutboundCallController())
+ await run_sample
+
+ else:
+ Logger.log_message(Logger.INFORMATION,
+ "Failed to start Ngrok service")
+
+ except Exception as ex:
+ Logger.log_message(
+ Logger.ERROR, "Failed to start Ngrok service --> "+str(ex))
+
+ Logger.log_message(Logger.INFORMATION,
+ "Press 'Ctrl + C' to exit the sample")
+ self.__ngrok_service.dispose()
+
+ def start_ngrok_service(self):
+ try:
+ ngrokPath = self.configuration_manager.get_app_settings(
+ "NgrokExePath")
+
+ if (not(len(ngrokPath))):
+ Logger.log_message(Logger.INFORMATION,
+ "Ngrok path not provided")
+ return None
+
+ Logger.log_message(Logger.INFORMATION, "Starting Ngrok")
+ self.__ngrok_service = NgrokService(ngrokPath, None)
+
+ Logger.log_message(Logger.INFORMATION, "Fetching Ngrok Url")
+ ngrok_url = self.__ngrok_service.get_ngrok_url()
+
+ Logger.log_message(Logger.INFORMATION,
+ "Ngrok Started with url -- > " + ngrok_url)
+ return ngrok_url
+
+ except Exception as ex:
+ Logger.log_message(Logger.INFORMATION,
+ "Ngrok service got failed -- > " + str(ex))
+ return None
+
+ async def run_sample(self, app_base_url):
+ call_configuration = self.initiate_configuration(app_base_url)
+ try:
+ outbound_call_pairs = self.configuration_manager.get_app_settings(
+ "DestinationIdentities")
+
+ if (outbound_call_pairs and len(outbound_call_pairs)):
+ identities = outbound_call_pairs.split(";")
+ tasks = []
+ for identity in identities:
+ pair = identity.split(",")
+ task = asyncio.ensure_future(OutboundCallReminder(
+ call_configuration).report(pair[0].strip(), pair[1].strip()))
+ tasks.append(task)
+
+ _ = await asyncio.gather(*tasks)
+
+ except Exception as ex:
+ Logger.log_message(
+ Logger.ERROR, "Failed to initiate the outbound call Exception -- > " + str(ex))
+
+ self.delete_user(call_configuration.connection_string,
+ call_configuration.source_identity)
+
+ #
+ # Fetch configurations from App Settings and create source identity
+ #
+ # The base url of the app.
+ # The
+
+ def initiate_configuration(self, app_base_url):
+ connection_string = self.configuration_manager.get_app_settings(
+ "Connectionstring")
+ source_phone_number = self.configuration_manager.get_app_settings(
+ "SourcePhone")
+
+ source_identity = self.create_user(connection_string)
+ audio_file_name = self.generate_custom_audio_message()
+
+ return CallConfiguration(connection_string, source_identity, source_phone_number, app_base_url, audio_file_name)
+
+ #
+ # Get .wav Audio file
+ #
+
+ def generate_custom_audio_message(self):
+ configuration_manager = ConfigurationManager()
+ key = configuration_manager.get_app_settings("CognitiveServiceKey")
+ region = configuration_manager.get_app_settings(
+ "CognitiveServiceRegion")
+ custom_message = configuration_manager.get_app_settings(
+ "CustomMessage")
+
+ try:
+ if (key and len(key) and region and len(region) and custom_message and len(custom_message)):
+
+ config = SpeechConfig(subscription=key, region=region)
+ config.set_speech_synthesis_output_format(
+ SpeechSynthesisOutputFormat["Riff24Khz16BitMonoPcm"])
+
+ synthesizer = SpeechSynthesizer(SpeechSynthesizer=config)
+
+ result = synthesizer.speak_text_async(custom_message).get()
+ stream = AudioDataStream(result)
+ stream.save_to_wav_file("/audio/custom-message.wav")
+
+ return "custom-message.wav"
+
+ return "sample-message.wav"
+ except Exception as ex:
+ Logger.log_message(
+ Logger.ERROR, "Exception while generating text to speech, falling back to sample audio. Exception -- > " + str(ex))
+ return "sample-message.wav"
+
+ #
+ # Create new user
+ #
+
+ 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__":
+ nest_asyncio.apply()
+ obj = Program()
+ asyncio.run(obj.program())
diff --git a/OutboundCallReminder/readme.md b/OutboundCallReminder/readme.md
new file mode 100644
index 0000000..286ccf5
--- /dev/null
+++ b/OutboundCallReminder/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/OutboundCallReminder/requirements.txt b/OutboundCallReminder/requirements.txt
new file mode 100644
index 0000000..0ac02f3
--- /dev/null
+++ b/OutboundCallReminder/requirements.txt
@@ -0,0 +1,32 @@
+aiohttp==3.7.4.post0
+async-timeout==3.0.1
+attrs==21.2.0
+azure-cognitiveservices-speech==1.18.0
+azure-common==1.1.27
+# format : @ file:///D:/sdk/dist/azure_communication_callingserver-1.0.0b1-py2.py3-none-any.whl
+azure-communication-callingserver #%whl file path%
+azure-communication-chat==1.0.0
+azure-communication-identity==1.0.1
+azure-core==1.17.0
+azure-nspkg==3.0.2
+azure-storage==0.36.0
+certifi==2021.5.30
+cffi==1.14.6
+chardet==4.0.0
+charset-normalizer==2.0.4
+cryptography==3.4.8
+idna==3.2
+isodate==0.6.0
+msrest==0.6.21
+multidict==5.1.0
+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==3.10.0.0
+urllib3==1.26.6
+yarl==1.6.3
\ 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..f9e2e45
--- /dev/null
+++ b/ServerRecording/Controller/CallRecordingController.py
@@ -0,0 +1,362 @@
+from azure.eventgrid import EventGridEvent
+from azure.eventgrid._generated.models import SubscriptionValidationEventData, \
+ AcsRecordingFileStatusUpdatedEventData, AcsRecordingChunkInfoProperties
+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
+
+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():
+
+ def __init__(self):
+ app = web.Application()
+ app.add_routes(
+ [web.get('/startRecording', CallRecordingController.start_recording)])
+ 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 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: SubscriptionValidationEventData = 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: AcsRecordingFileStatusUpdatedEventData = event_data
+ acs_recording_chunk_info_properties: AcsRecordingChunkInfoProperties = \
+ 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_recording_response = CallRecordingController.process_file(
+ document_id,
+ content_location,
+ 'mp4',
+ 'recording')
+
+ if process_recording_response is True:
+ Logger.log_message(
+ Logger.INFORMATION, "Start processing metadata -- >")
+
+ process_metadata_response = CallRecordingController.process_file(
+ document_id,
+ metadata_location,
+ 'json',
+ 'metadata')
+
+ if process_metadata_response is True:
+ Logger.log_message(
+ Logger.INFORMATION, "Processing recording and metadata files completed successfully.")
+ else:
+ Logger.log_message(
+ Logger.INFORMATION, "Processing metadata file failed with message --> " + str(process_metadata_response))
+ else:
+ Logger.log_message(
+ Logger.INFORMATION, "Processing recording file failed with message --> " + str(process_recording_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()
+
+ 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/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/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