diff --git a/app/clients/sms/twilio.py b/app/clients/sms/twilio.py index 96cfbc0f7f..1065a2866c 100644 --- a/app/clients/sms/twilio.py +++ b/app/clients/sms/twilio.py @@ -5,11 +5,14 @@ from urllib.parse import parse_qs from twilio.rest import Client +from twilio.rest.api.v2010.account.message import MessageInstance from twilio.base.exceptions import TwilioRestException from app.clients.sms import SmsClient, SmsStatusRecord + from app.exceptions import InvalidProviderException + TWILIO_RESPONSE_MAP = { 'accepted': 'created', 'queued': 'sending', @@ -125,6 +128,23 @@ def name(self): def get_name(self): return self.name + def get_twilio_message(self, message_sid: str) -> MessageInstance | None: + """ + Fetches a Twilio message by its message sid. + + Args: + message_sid (str): the Twilio message id + + Returns: + MessageInstance: the Twilio message instance if found, otherwise None + """ + message = None + try: + message = self._client.messages(message_sid).fetch() + except TwilioRestException: + self.logger.warning('Twilio message not found: %s', message_sid) + return message + def send_sms( self, to, @@ -232,22 +252,104 @@ def translate_delivery_status( self.logger.info('Translating Twilio delivery status') self.logger.debug(twilio_delivery_status_message) + decoded_msg, parsed_dict = self._parse_twilio_message(twilio_delivery_status_message) + + message_sid = parsed_dict['MessageSid'][0] + twilio_delivery_status = parsed_dict['MessageStatus'][0] + error_code = parsed_dict.get('ErrorCode', []) + + status, status_reason = self._evaluate_status(message_sid, twilio_delivery_status, error_code) + + status = SmsStatusRecord( + decoded_msg, + message_sid, + status, + status_reason, + ) + + self.logger.debug('Twilio delivery status translation: %s', status) + + return status + + def update_notification_status_override(self, message_sid: str) -> None: + """ + Updates the status of the notification based on the Twilio message status, bypassing any logic. + + Args: + message_sid (str): the Twilio message id + + Returns: + None + """ + # Importing inline to resolve a circular import error when importing at the top of the file + from app.dao.notifications_dao import dao_update_notifications_by_reference + + self.logger.info('Updating notification status for message: %s', message_sid) + + message = self.get_twilio_message(message_sid) + self.logger.debug('Twilio message: %s', message) + + if message: + status, status_reason = self._evaluate_status(message_sid, message.status, []) + update_dict = { + 'status': status, + 'status_reason': status_reason, + } + updated_count, updated_history_count = dao_update_notifications_by_reference( + [ + message_sid, + ], + update_dict, + ) + self.logger.info( + 'Updated notification status for message: %s. Updated %s notifications and %s notification history', + message_sid, + updated_count, + updated_history_count, + ) + + def _parse_twilio_message(self, twilio_delivery_status_message: MessageInstance) -> tuple[str, dict]: + """ + Parses the base64 encoded delivery status message from Twilio and returns a dictionary. + + Args: + twilio_delivery_status_message (str): the base64 encoded Twilio delivery status message + + Returns: + tuple: a tuple containing the decoded message and a dictionary of the parsed message + + Raises: + ValueError: if the Twilio delivery status message is empty + """ if not twilio_delivery_status_message: raise ValueError('Twilio delivery status message is empty') decoded_msg = base64.b64decode(twilio_delivery_status_message).decode() - parsed_dict = parse_qs(decoded_msg) - message_sid = parsed_dict['MessageSid'][0] - twilio_delivery_status = parsed_dict['MessageStatus'][0] - error_code_data = parsed_dict.get('ErrorCode', None) + return decoded_msg, parsed_dict + + def _evaluate_status(self, message_sid: str, twilio_delivery_status: str, error_codes: list) -> tuple[str, str]: + """ + Evaluates the Twilio delivery status and error codes to determine the notification status. + + Args: + message_sid (str): the Twilio message id + twilio_delivery_status (str): the Twilio message status + error_codes (list): the Twilio error codes + + Returns: + tuple: a tuple containing the notification status and status reason + + Raises: + ValueError: if the Twilio delivery status is invalid + """ if twilio_delivery_status not in self.twilio_notify_status_map: value_error = f'Invalid Twilio delivery status: {twilio_delivery_status}' raise ValueError(value_error) - if error_code_data and (twilio_delivery_status == 'failed' or twilio_delivery_status == 'undelivered'): - error_code = error_code_data[0] + if error_codes and (twilio_delivery_status == 'failed' or twilio_delivery_status == 'undelivered'): + error_code = error_codes[0] if error_code in self.twilio_error_code_map: notify_delivery_status: TwilioStatus = self.twilio_error_code_map[error_code] @@ -258,21 +360,12 @@ def translate_delivery_status( notify_delivery_status: TwilioStatus = self.twilio_notify_status_map[twilio_delivery_status] else: # Logic not being changed, just want to log this for now - if error_code_data: + if error_codes: self.logger.warning( 'Error code: %s existed but status for message: %s was not failed nor undelivered', - error_code_data[0], + error_codes[0], message_sid, ) notify_delivery_status: TwilioStatus = self.twilio_notify_status_map[twilio_delivery_status] - status = SmsStatusRecord( - decoded_msg, - message_sid, - notify_delivery_status.status, - notify_delivery_status.status_reason, - ) - - self.logger.debug('Twilio delivery status translation: %s', status) - - return status + return notify_delivery_status.status, notify_delivery_status.status_reason diff --git a/tests/app/clients/test_twilio.py b/tests/app/clients/test_twilio.py index dc019ace4c..d8fb1fb85f 100644 --- a/tests/app/clients/test_twilio.py +++ b/tests/app/clients/test_twilio.py @@ -687,3 +687,72 @@ def test_send_sms_raises_invalid_provider_error_with_invalid_twilio_number( ) twilio_sms_client.send_sms(to, content, reference) + + +def test_get_twilio_message( + notify_api, + mocker, +): + twilio_sid = 'test_sid' + response_dict = make_twilio_message_response_dict() + response_dict['sid'] = twilio_sid + + with requests_mock.Mocker() as r_mock: + r_mock.get( + f'https://api.twilio.com/2010-04-01/Accounts/{twilio_sms_client._account_sid}/Messages/{twilio_sid}.json', + json=response_dict, + status_code=200, + ) + + response = twilio_sms_client.get_twilio_message(twilio_sid) + + assert response.sid == twilio_sid + assert response.status == 'sent' + + +def test_update_notification_status_override( + notify_api, + mocker, + sample_notification, + notify_db_session, +): + response_dict = make_twilio_message_response_dict() + response_dict['sid'] = 'test_sid' + response_dict['status'] = 'delivered' + twilio_sid = response_dict['sid'] + + notification = sample_notification(status='sending', reference=twilio_sid) + + with requests_mock.Mocker() as r_mock: + r_mock.get( + f'https://api.twilio.com/2010-04-01/Accounts/{twilio_sms_client._account_sid}/Messages/{twilio_sid}.json', + json=response_dict, + status_code=200, + ) + + twilio_sms_client.update_notification_status_override(twilio_sid) + + # Retrieve the updated notification + notify_db_session.session.refresh(notification) + assert notification.status == 'delivered' + + +def test_update_notification_with_unknown_sid( + notify_api, + mocker, + sample_notification, + notify_db_session, +): + twilio_sid = 'test_sid' + + notification = sample_notification(status='sending', reference=twilio_sid) + + # Mock the call to get_twilio_message + mocker.patch('app.clients') + mocker.patch('app.clients.sms.twilio.TwilioSMSClient.get_twilio_message', side_effect=TwilioRestException) + + twilio_sms_client.update_notification_status_override(twilio_sid) + + # Retrieve the updated notification + notify_db_session.session.refresh(notification) + assert notification.status == 'sending'