-
Notifications
You must be signed in to change notification settings - Fork 48
/
function_app.py
1046 lines (918 loc) · 32.6 KB
/
function_app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import asyncio
import json
from datetime import timedelta
from http import HTTPStatus
from os import getenv
from typing import Any, Optional, Union
from urllib.parse import quote_plus, urljoin
from uuid import UUID
import azure.functions as func
import jwt
import mistune
from azure.communication.callautomation import PhoneNumberIdentifier
from azure.communication.callautomation.aio import CallAutomationClient
from azure.core.credentials import AzureKeyCredential
from azure.core.messaging import CloudEvent
from azure.eventgrid import EventGridEvent, SystemEventNames
from htmlmin.minify import html_minify
from jinja2 import Environment, FileSystemLoader
from pydantic import TypeAdapter, ValidationError
from twilio.twiml.messaging_response import MessagingResponse
from helpers.call_events import (
on_call_connected,
on_call_disconnected,
on_end_call,
on_ivr_recognized,
on_new_call,
on_play_completed,
on_play_error,
on_recognize_timeout_error,
on_recognize_unknown_error,
on_sms_received,
on_speech_recognized,
on_transfer_completed,
on_transfer_error,
)
from helpers.call_utils import ContextEnum as CallContextEnum
from helpers.config import CONFIG
from helpers.http import azure_transport
from helpers.logging import logger
from helpers.monitoring import CallAttributes, span_attribute, tracer
from helpers.pydantic_types.phone_numbers import PhoneNumber
from helpers.resources import resources_dir
from models.call import CallGetModel, CallInitiateModel, CallStateModel
from models.next import ActionEnum as NextActionEnum
from models.readiness import ReadinessCheckModel, ReadinessEnum, ReadinessModel
logger.info(
"call-center-ai v%s (Azure Functions v%s)",
CONFIG.version,
getattr(func, "__version__"),
)
# Jinja configuration
_jinja = Environment(
autoescape=True,
enable_async=True,
loader=FileSystemLoader("public_website"),
optimized=False, # Outsource optimization to html_minify
)
# Jinja custom functions
_jinja.filters["quote_plus"] = lambda x: quote_plus(str(x)) if x else ""
_jinja.filters["markdown"] = lambda x: (
mistune.create_markdown(plugins=["abbr", "speedup", "url"])(x) if x else ""
) # pyright: ignore
# Azure Communication Services
_automation_client: Optional[CallAutomationClient] = None
_source_caller = PhoneNumberIdentifier(CONFIG.communication_services.phone_number)
logger.info("Using phone number %s", CONFIG.communication_services.phone_number)
_communication_services_jwks_client = jwt.PyJWKClient(
cache_keys=True,
uri="https://acscallautomation.communication.azure.com/calling/keys",
)
# Persistences
_cache = CONFIG.cache.instance()
_db = CONFIG.database.instance()
_search = CONFIG.ai_search.instance()
_sms = CONFIG.sms.instance()
# Azure Functions
app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)
# Communication Services callback
assert CONFIG.public_domain, "public_domain config is not set"
_COMMUNICATIONSERVICES_CALLABACK_TPL = urljoin(
str(CONFIG.public_domain),
"/communicationservices/event/{call_id}/{callback_secret}",
)
logger.info("Using call event URL %s", _COMMUNICATIONSERVICES_CALLABACK_TPL)
@app.route(
"openapi.json",
methods=["GET"],
)
@tracer.start_as_current_span("openapi_get")
async def openapi_get(req: func.HttpRequest) -> func.HttpResponse:
"""
Generate the OpenAPI specification for the API.
No parameters are expected.
Returns a JSON object with the OpenAPI specification.
"""
with open(
encoding="utf-8",
file=resources_dir("openapi.json"),
mode="r",
) as f:
openapi = json.load(f)
openapi["info"]["version"] = CONFIG.version
openapi["servers"] = [
{
"description": "Public endpoint",
"url": str(CONFIG.public_domain),
}
]
return func.HttpResponse(
body=json.dumps(openapi),
mimetype="application/json",
status_code=HTTPStatus.OK,
)
@app.route(
"health/liveness",
methods=["GET"],
)
@tracer.start_as_current_span("health_liveness_get")
async def health_liveness_get(req: func.HttpRequest) -> func.HttpResponse:
"""
Check if the service is running.
No parameters are expected.
Returns a 200 OK if the service is technically running.
"""
return func.HttpResponse(status_code=HTTPStatus.NO_CONTENT)
@app.route(
"health/readiness",
methods=["GET"],
)
@tracer.start_as_current_span("health_readiness_get")
async def health_readiness_get(req: func.HttpRequest) -> func.HttpResponse:
"""
Check if the service is ready to serve requests.
No parameters are expected. Services tested are: cache, store, search, sms.
Returns a 200 OK if the service is ready to serve requests. If the service is not ready, it should return a 503 Service Unavailable.
"""
# Check all components in parallel
(
cache_check,
store_check,
search_check,
sms_check,
) = await asyncio.gather(
_cache.areadiness(),
_db.areadiness(),
_search.areadiness(),
_sms.areadiness(),
)
readiness = ReadinessModel(
status=ReadinessEnum.OK,
checks=[
ReadinessCheckModel(id="cache", status=cache_check),
ReadinessCheckModel(id="store", status=store_check),
ReadinessCheckModel(id="startup", status=ReadinessEnum.OK),
ReadinessCheckModel(id="search", status=search_check),
ReadinessCheckModel(id="sms", status=sms_check),
],
)
# If one of the checks fails, the whole readiness fails
status_code = HTTPStatus.OK
for check in readiness.checks:
if check.status != ReadinessEnum.OK:
readiness.status = ReadinessEnum.FAIL
status_code = HTTPStatus.SERVICE_UNAVAILABLE
break
return func.HttpResponse(
body=readiness.model_dump_json(),
mimetype="application/json",
status_code=status_code,
)
@app.route(
"report",
methods=["GET"],
trigger_arg_name="req",
)
@tracer.start_as_current_span("report_get")
async def report_get(req: func.HttpRequest) -> func.HttpResponse:
"""
List all calls with a web interface.
Optional URL parameters:
- phone_number: Filter by phone number
Returns a list of calls with a web interface.
"""
try:
phone_number = (
PhoneNumber(req.params["phone_number"])
if "phone_number" in req.params
else None
)
except ValueError as e:
return _validation_error(e)
count = 100
calls, total = (
await _db.call_asearch_all(
count=count,
phone_number=phone_number or None,
)
or []
)
template = _jinja.get_template("list.html.jinja")
render = await template.render_async(
applicationinsights_connection_string=getenv(
"APPLICATIONINSIGHTS_CONNECTION_STRING"
),
bot_phone_number=CONFIG.communication_services.phone_number,
calls=calls or [],
count=count,
phone_number=phone_number,
total=total,
version=CONFIG.version,
)
render = html_minify(render) # Minify HTML
return func.HttpResponse(
body=render,
mimetype="text/html",
status_code=HTTPStatus.OK,
)
@app.route(
"report/{call_id:guid}",
methods=["GET"],
trigger_arg_name="req",
)
@tracer.start_as_current_span("report_single_get")
async def report_single_get(req: func.HttpRequest) -> func.HttpResponse:
"""
Show a single call with a web interface.
No parameters are expected.
Returns a single call with a web interface.
"""
try:
call_id = UUID(req.route_params["call_id"])
except ValueError as e:
return _validation_error(e)
call = await _db.call_aget(call_id)
if not call:
return _standard_error(
message=f"Call {call_id} not found",
status_code=HTTPStatus.NOT_FOUND,
)
template = _jinja.get_template("single.html.jinja")
render = await template.render_async(
applicationinsights_connection_string=getenv(
"APPLICATIONINSIGHTS_CONNECTION_STRING"
),
bot_company=call.initiate.bot_company,
bot_name=call.initiate.bot_name,
bot_phone_number=CONFIG.communication_services.phone_number,
call=call,
next_actions=[action for action in NextActionEnum],
version=CONFIG.version,
)
render = html_minify(render) # Minify HTML
return func.HttpResponse(
body=render,
mimetype="text/html",
status_code=HTTPStatus.OK,
)
# TODO: Add total (int) and calls (list) as a wrapper for the list of calls
@app.route(
"call",
methods=["GET"],
trigger_arg_name="req",
)
@tracer.start_as_current_span("call_list_get")
async def call_list_get(req: func.HttpRequest) -> func.HttpResponse:
"""
REST API to list all calls.
Parameters:
- phone_number: Filter by phone number
Returns a list of calls objects `CallGetModel`, for a phone number, in JSON format.
"""
try:
phone_number = (
PhoneNumber(req.params["phone_number"])
if "phone_number" in req.params
else None
)
except ValueError as e:
return _validation_error(e)
count = 100
calls, _ = await _db.call_asearch_all(phone_number=phone_number, count=count)
if not calls:
return _standard_error(
message=f"Calls {phone_number} not found",
status_code=HTTPStatus.NOT_FOUND,
)
output = [CallGetModel.model_validate(call) for call in calls or []]
return func.HttpResponse(
body=TypeAdapter(list[CallGetModel]).dump_json(output),
mimetype="application/json",
status_code=HTTPStatus.OK,
)
@app.route(
"call/{phone_number}",
methods=["GET"],
trigger_arg_name="req",
)
@tracer.start_as_current_span("call_phone_number_get")
async def call_phone_number_get(req: func.HttpRequest) -> func.HttpResponse:
"""
REST API to search for calls by phone number.
Parameters:
- phone_number: Phone number to search for
Returns a single call object `CallGetModel`, in JSON format.
"""
try:
phone_number = PhoneNumber(req.route_params["phone_number"])
except ValueError as e:
return _validation_error(e)
call = await _db.call_asearch_one(phone_number=phone_number)
if not call:
return _standard_error(
message=f"Call {phone_number} not found",
status_code=HTTPStatus.NOT_FOUND,
)
return func.HttpResponse(
body=TypeAdapter(CallGetModel).dump_json(call),
mimetype="application/json",
status_code=HTTPStatus.OK,
)
@app.route(
"call/{call_id:guid}",
methods=["GET"],
trigger_arg_name="req",
)
@tracer.start_as_current_span("call_id_get")
async def call_id_get(req: func.HttpRequest) -> func.HttpResponse:
""" "
REST API to get a single call by call ID.
Parameters:
- call_id: Call ID to search for
Returns a single call object `CallGetModel`, in JSON format.
"""
try:
call_id = UUID(req.route_params["call_id"])
except ValueError as e:
return _validation_error(e)
call = await _db.call_aget(call_id)
if not call:
return _standard_error(
message=f"Call {call_id} not found",
status_code=HTTPStatus.NOT_FOUND,
)
return func.HttpResponse(
body=TypeAdapter(CallGetModel).dump_json(call),
mimetype="application/json",
status_code=HTTPStatus.OK,
)
@app.route(
"call",
methods=["POST"],
trigger_arg_name="req",
)
@tracer.start_as_current_span("call_post")
async def call_post(req: func.HttpRequest) -> func.HttpResponse:
"""
REST API to initiate a call.
Required body parameters is a JSON object `CallInitiateModel`.
Returns a single call object `CallGetModel`, in JSON format.
"""
try:
initiate = CallInitiateModel.model_validate_json(req.get_body())
except ValidationError as e:
return _validation_error(e)
url, call = await _communicationservices_event_url(initiate.phone_number, initiate)
span_attribute(CallAttributes.CALL_ID, str(call.call_id))
span_attribute(CallAttributes.CALL_PHONE_NUMBER, call.initiate.phone_number)
automation_client = await _use_automation_client()
call_connection_properties = await automation_client.create_call(
callback_url=url,
cognitive_services_endpoint=CONFIG.cognitive_service.endpoint,
source_caller_id_number=_source_caller,
# deepcode ignore AttributeLoadOnNone: Phone number is validated with Pydantic
target_participant=PhoneNumberIdentifier(
initiate.phone_number
), # pyright: ignore
)
logger.info(
"Created call with connection id: %s",
call_connection_properties.call_connection_id,
)
return func.HttpResponse(
body=TypeAdapter(CallGetModel).dump_json(call),
mimetype="application/json",
status_code=HTTPStatus.CREATED,
)
@app.queue_trigger(
arg_name="call",
connection="Storage",
queue_name=CONFIG.communication_services.call_queue_name,
)
@tracer.start_as_current_span("call_event")
async def call_event(
call: func.QueueMessage,
) -> None:
"""
Handle incoming call event from Azure Communication Services.
The event will trigger the workflow to start a new call.
Queue message is a JSON object `EventGridEvent` with an event type of `AcsIncomingCallEventName`.
"""
event = EventGridEvent.from_json(call.get_body())
event_type = event.event_type
logger.debug("Call event with data %s", event.data)
if not event_type == SystemEventNames.AcsIncomingCallEventName:
logger.warning("Event %s not supported", event_type)
return
call_context: str = event.data["incomingCallContext"]
phone_number = PhoneNumber(event.data["from"]["phoneNumber"]["value"])
url, _call = await _communicationservices_event_url(phone_number)
span_attribute(CallAttributes.CALL_ID, str(_call.call_id))
span_attribute(CallAttributes.CALL_PHONE_NUMBER, _call.initiate.phone_number)
await on_new_call(
callback_url=url,
client=await _use_automation_client(),
incoming_context=call_context,
phone_number=phone_number,
)
@app.queue_trigger(
arg_name="sms",
connection="Storage",
queue_name=CONFIG.communication_services.sms_queue_name,
)
@app.queue_output(
arg_name="trainings",
connection="Storage",
queue_name=CONFIG.communication_services.trainings_queue_name,
)
@app.queue_output(
arg_name="post",
connection="Storage",
queue_name=CONFIG.communication_services.post_queue_name,
)
@tracer.start_as_current_span("sms_event")
async def sms_event(
post: func.Out[str],
sms: func.QueueMessage,
trainings: func.Out[str],
) -> None:
"""
Handle incoming SMS event from Azure Communication Services.
The event will trigger the workflow to handle a new SMS message.
Returns None. Can trigger additional events to `trainings` and `post` queues.
"""
event = EventGridEvent.from_json(sms.get_body())
event_type = event.event_type
logger.debug("SMS event with data %s", event.data)
if not event_type == SystemEventNames.AcsSmsReceivedEventName:
logger.warning("Event %s not supported", event_type)
return
message: str = event.data["message"]
phone_number: str = event.data["from"]
span_attribute(CallAttributes.CALL_PHONE_NUMBER, phone_number)
call = await _db.call_asearch_one(phone_number)
if not call:
logger.warning("Call for phone number %s not found", phone_number)
return
span_attribute(CallAttributes.CALL_ID, str(call.call_id))
async def _post_callback(_call: CallStateModel) -> None:
_trigger_post_event(call=_call, post=post)
async def _trainings_callback(_call: CallStateModel) -> None:
_trigger_trainings_event(call=_call, trainings=trainings)
await on_sms_received(
call=call,
client=await _use_automation_client(),
message=message,
post_callback=_post_callback,
trainings_callback=_trainings_callback,
)
@app.route(
"communicationservices/event/{call_id:guid}/{secret:length(16)}",
methods=["POST"],
trigger_arg_name="req",
)
@app.queue_output(
arg_name="trainings",
connection="Storage",
queue_name=CONFIG.communication_services.trainings_queue_name,
)
@app.queue_output(
arg_name="post",
connection="Storage",
queue_name=CONFIG.communication_services.post_queue_name,
)
@tracer.start_as_current_span("communicationservices_event_post")
async def communicationservices_event_post(
post: func.Out[str],
req: func.HttpRequest,
trainings: func.Out[str],
) -> func.HttpResponse:
"""
Handle direct events from Azure Communication Services for a running call.
No parameters are expected. The body is a list of JSON objects `CloudEvent`.
Returns a 204 No Content if the events are properly fomatted. A 401 Unauthorized if the JWT token is invalid. Otherwise, returns a 400 Bad Request.
"""
# Validate JWT token
service_jwt: Union[str, None] = req.headers.get("Authorization")
if not service_jwt:
return _standard_error(
message="Authorization header missing",
status_code=HTTPStatus.UNAUTHORIZED,
)
service_jwt = str(service_jwt).replace("Bearer ", "")
try:
jwt.decode(
algorithms=["RS256"],
audience=CONFIG.communication_services.resource_id,
issuer="https://acscallautomation.communication.azure.com",
jwt=service_jwt,
leeway=timedelta(
minutes=5
), # Recommended practice by Azure to mitigate clock skew
key=_communication_services_jwks_client.get_signing_key_from_jwt(
service_jwt
).key,
)
except jwt.PyJWTError:
logger.warning("Invalid JWT token", exc_info=True)
return _standard_error(
message="Invalid JWT token",
status_code=HTTPStatus.UNAUTHORIZED,
)
# Validate request
try:
call_id = UUID(req.route_params["call_id"])
secret: str = req.route_params["secret"]
except ValueError as e:
return _validation_error(e)
try:
events = req.get_json()
except ValueError:
return _validation_error(Exception("Invalid JSON format"))
if not events or not isinstance(events, list):
return _validation_error(Exception("Events must be a list"))
# Process events in parallel
await asyncio.gather(
*[
_communicationservices_event_worker(
call_id=call_id,
event_dict=event,
post=post,
secret=secret,
trainings=trainings,
)
for event in events
]
)
# Return default response
return func.HttpResponse(status_code=HTTPStatus.NO_CONTENT)
async def _communicationservices_event_worker(
call_id: UUID,
event_dict: dict,
post: func.Out[str],
secret: str,
trainings: func.Out[str],
) -> None:
"""
Worker to handle a single event from Azure Communication Services.
The event will trigger the workflow to handle a new event for a running call:
- Call connected
- Call disconnected
- Call transfer accepted
- Call transfer failed
- Play completed
- Play failed
- Recognize completed
- Recognize failed
Returns None. Can trigger additional events to `trainings` and `post` queues.
"""
span_attribute(CallAttributes.CALL_ID, str(call_id))
call = await _db.call_aget(call_id)
if not call:
logger.warning("Call %s not found", call_id)
return
if call.callback_secret != secret:
logger.warning("Secret for call %s does not match", call_id)
return
span_attribute(CallAttributes.CALL_PHONE_NUMBER, call.initiate.phone_number)
# Event parsing
event = CloudEvent.from_dict(event_dict)
assert isinstance(event.data, dict)
# Store connection ID
connection_id = event.data["callConnectionId"]
call.voice_id = connection_id
# Extract context
event_type = event.type
# Extract event context
operation_context = event.data.get("operationContext", None)
operation_contexts = _str_to_contexts(operation_context)
# Client SDK
automation_client = await _use_automation_client()
logger.debug("Call event received %s for call %s", event_type, call)
logger.debug(event.data)
async def _post_callback(_call: CallStateModel) -> None:
_trigger_post_event(call=_call, post=post)
async def _trainings_callback(_call: CallStateModel) -> None:
_trigger_trainings_event(call=_call, trainings=trainings)
if event_type == "Microsoft.Communication.CallConnected": # Call answered
await on_call_connected(
call=call,
client=automation_client,
)
elif event_type == "Microsoft.Communication.CallDisconnected": # Call hung up
await on_call_disconnected(
call=call,
client=automation_client,
post_callback=_post_callback,
)
elif (
event_type == "Microsoft.Communication.RecognizeCompleted"
): # Speech recognized
recognition_result: str = event.data["recognitionType"]
if recognition_result == "speech": # Handle voice
speech_text: Optional[str] = event.data["speechResult"]["speech"]
if speech_text:
await on_speech_recognized(
call=call,
client=automation_client,
post_callback=_post_callback,
text=speech_text,
trainings_callback=_trainings_callback,
)
elif recognition_result == "choices": # Handle IVR
label_detected: str = event.data["choiceResult"]["label"]
await on_ivr_recognized(
call=call,
client=automation_client,
label=label_detected,
post_callback=_post_callback,
trainings_callback=_trainings_callback,
)
elif (
event_type == "Microsoft.Communication.RecognizeFailed"
): # Speech recognition failed
result_information = event.data["resultInformation"]
error_code: int = result_information["subCode"]
error_message: str = result_information["message"]
logger.debug(
"Speech recognition failed with error code %s: %s",
error_code,
error_message,
)
# Error codes:
# 8510 = Action failed, initial silence timeout reached
# 8532 = Action failed, inter-digit silence timeout reached
# See: https://github.com/MicrosoftDocs/azure-docs/blob/main/articles/communication-services/how-tos/call-automation/recognize-action.md#event-codes
if error_code in (8510, 8532): # Timeout retry
await on_recognize_timeout_error(
call=call,
client=automation_client,
contexts=operation_contexts,
)
else: # Unknown error
await on_recognize_unknown_error(
call=call,
client=automation_client,
error_code=error_code,
)
elif event_type == "Microsoft.Communication.PlayCompleted": # Media played
await on_play_completed(
call=call,
client=automation_client,
contexts=operation_contexts,
post_callback=_post_callback,
)
elif event_type == "Microsoft.Communication.PlayFailed": # Media play failed
result_information = event.data["resultInformation"]
error_code: int = result_information["subCode"]
await on_play_error(error_code)
elif (
event_type == "Microsoft.Communication.CallTransferAccepted"
): # Call transfer accepted
await on_transfer_completed()
elif (
event_type == "Microsoft.Communication.CallTransferFailed"
): # Call transfer failed
result_information = event.data["resultInformation"]
sub_code: int = result_information["subCode"]
await on_transfer_error(
call=call,
client=automation_client,
error_code=sub_code,
)
await _db.call_aset(
call
) # TODO: Do not persist on every event, this is simpler but not efficient
@app.queue_trigger(
arg_name="trainings",
connection="Storage",
queue_name=CONFIG.communication_services.trainings_queue_name,
)
@tracer.start_as_current_span("trainings_event")
async def trainings_event(
trainings: func.QueueMessage,
) -> None:
"""
Handle trainings event from the queue.
Queue message is a JSON object `CallStateModel`. The event will load asynchroniously the trainings for a call.
Returns None.
"""
call = CallStateModel.model_validate_json(trainings.get_body())
logger.debug("Trainings event received for call %s", call)
span_attribute(CallAttributes.CALL_ID, str(call.call_id))
span_attribute(CallAttributes.CALL_PHONE_NUMBER, call.initiate.phone_number)
await call.trainings(cache_only=False) # Get trainings by advance to populate cache
@app.queue_trigger(
arg_name="post",
connection="Storage",
queue_name=CONFIG.communication_services.post_queue_name,
)
@tracer.start_as_current_span("post_event")
async def post_event(
post: func.QueueMessage,
) -> None:
"""
Handle post-call intelligence event from the queue.
Queue message is a JSON object `CallStateModel`. The event will load asynchroniously the `on_end_call` workflow.
"""
call = CallStateModel.model_validate_json(post.get_body())
logger.debug("Post event received for call %s", call)
span_attribute(CallAttributes.CALL_ID, str(call.call_id))
span_attribute(CallAttributes.CALL_PHONE_NUMBER, call.initiate.phone_number)
await on_end_call(call)
def _trigger_trainings_event(
call: CallStateModel,
trainings: func.Out[str],
) -> None:
"""
Shortcut to add trainings to the queue.
"""
trainings.set(call.model_dump_json(exclude_none=True))
def _trigger_post_event(
call: CallStateModel,
post: func.Out[str],
) -> None:
"""
Shortcut to add post-call intelligence to the queue.
"""
post.set(call.model_dump_json(exclude_none=True))
async def _communicationservices_event_url(
phone_number: PhoneNumber, initiate: Optional[CallInitiateModel] = None
) -> tuple[str, CallStateModel]:
"""
Generate the callback URL for a call.
If the caller has already called, use the same call ID, to keep the conversation history. Otherwise, create a new call ID.
"""
call = await _db.call_asearch_one(phone_number)
if not call or (
initiate and call.initiate != initiate
): # Create new call if initiate is different
call = CallStateModel(
initiate=initiate
or CallInitiateModel(
**CONFIG.conversation.initiate.model_dump(),
phone_number=phone_number,
)
)
await _db.call_aset(call) # Create for the first time
url = _COMMUNICATIONSERVICES_CALLABACK_TPL.format(
callback_secret=call.callback_secret,
call_id=str(call.call_id),
)
return url, call
# TODO: Secure this endpoint with a secret, either in the Authorization header or in the URL
@app.route(
"twilio/sms",
methods=["POST"],
trigger_arg_name="req",
)
@app.queue_output(
arg_name="trainings",
connection="Storage",
queue_name=CONFIG.communication_services.trainings_queue_name,
)
@app.queue_output(
arg_name="post",
connection="Storage",
queue_name=CONFIG.communication_services.post_queue_name,
)
@tracer.start_as_current_span("twilio_sms_post")
async def twilio_sms_post(
post: func.Out[str],
req: func.HttpRequest,
trainings: func.Out[str],
) -> func.HttpResponse:
"""
Handle incoming SMS event from Twilio.
The event will trigger the workflow to handle a new SMS message.
Returns a 200 OK if the SMS is properly formatted. Otherwise, returns a 400 Bad Request.
"""
if not req.form:
return _validation_error(Exception("No form data"))
try:
phone_number = PhoneNumber(req.form["From"])
message: str = req.form["Body"]
except ValueError as e:
return _validation_error(e)
span_attribute(CallAttributes.CALL_PHONE_NUMBER, phone_number)
call = await _db.call_asearch_one(phone_number)
if not call:
logger.warning("Call for phone number %s not found", phone_number)
else:
span_attribute(CallAttributes.CALL_ID, str(call.call_id))
async def _post_callback(_call: CallStateModel) -> None:
_trigger_post_event(call=_call, post=post)
async def _trainings_callback(_call: CallStateModel) -> None:
_trigger_trainings_event(call=_call, trainings=trainings)
event_status = await on_sms_received(
call=call,
client=await _use_automation_client(),
message=message,
post_callback=_post_callback,
trainings_callback=_trainings_callback,
)
if not event_status:
return _standard_error(
message="SMS event failed",
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
)
return func.HttpResponse(
body=str(MessagingResponse()), # Twilio expects an empty response everytime
mimetype="application/xml",
status_code=HTTPStatus.OK,
)
def _str_to_contexts(value: Optional[str]) -> Optional[set[CallContextEnum]]:
"""
Convert a string to a set of contexts.
The string is a JSON array of strings.
Returns a set of `CallContextEnum` or None.
"""
if not value:
return None
try:
contexts = json.loads(value)
except (json.JSONDecodeError, TypeError):
return None
res = set()
for context in contexts:
try:
res.add(CallContextEnum(context))
except ValueError:
logger.warning("Unknown context %s, skipping", context)
return res or None
def _validation_error(
e: Exception,
) -> func.HttpResponse:
"""
Generate a standard validation error response.
Response body is a JSON object with the following structure:
```
{
"error": {
"message": "Validation error",
"details": ["Error message"]
}
}
```
Returns a 400 Bad Request with a JSON body.
"""
messages = []
if isinstance(e, ValidationError):
messages = [
str(x) for x in e.errors()
] # Pydantic returns well formatted errors, use them
elif isinstance(e, ValueError):
messages = [str(e)] # TODO: Could it expose sensitive information?
return _standard_error(
details=messages,
message="Validation error",
status_code=HTTPStatus.BAD_REQUEST,
)
def _standard_error(
message: str,
details: Optional[list[str]] = None,
status_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
) -> func.HttpResponse:
"""
Generate a standard error response.
Response body is a JSON object with the following structure: