mirror of
https://github.com/GSA/notifications-api.git
synced 2026-02-04 02:11:11 -05:00
implement SNS
This commit is contained in:
@@ -1,61 +1,25 @@
|
||||
from time import monotonic
|
||||
|
||||
from app.clients import Client, ClientException
|
||||
|
||||
|
||||
class SmsClientResponseException(ClientException):
|
||||
'''
|
||||
"""
|
||||
Base Exception for SmsClientsResponses
|
||||
'''
|
||||
"""
|
||||
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
def __str__(self):
|
||||
return f"SMS client error ({self.message})"
|
||||
return "Message {}".format(self.message)
|
||||
|
||||
|
||||
class SmsClient(Client):
|
||||
'''
|
||||
"""
|
||||
Base Sms client for sending smss.
|
||||
'''
|
||||
"""
|
||||
|
||||
def init_app(self, current_app, statsd_client):
|
||||
self.current_app = current_app
|
||||
self.statsd_client = statsd_client
|
||||
def send_sms(self, *args, **kwargs):
|
||||
raise NotImplementedError("TODO Need to implement.")
|
||||
|
||||
def record_outcome(self, success):
|
||||
log_message = "Provider request for {} {}".format(
|
||||
self.name,
|
||||
"succeeded" if success else "failed",
|
||||
)
|
||||
|
||||
if success:
|
||||
self.current_app.logger.info(log_message)
|
||||
self.statsd_client.incr(f"clients.{self.name}.success")
|
||||
else:
|
||||
self.statsd_client.incr(f"clients.{self.name}.error")
|
||||
self.current_app.logger.warning(log_message)
|
||||
|
||||
def send_sms(self, to, content, reference, international, sender):
|
||||
start_time = monotonic()
|
||||
|
||||
try:
|
||||
response = self.try_send_sms(to, content, reference, international, sender)
|
||||
self.record_outcome(True)
|
||||
except SmsClientResponseException as e:
|
||||
self.record_outcome(False)
|
||||
raise e
|
||||
finally:
|
||||
elapsed_time = monotonic() - start_time
|
||||
self.statsd_client.timing(f"clients.{self.name}.request-time", elapsed_time)
|
||||
self.current_app.logger.info(f"{self.name} request for {reference} finished in {elapsed_time}")
|
||||
|
||||
return response
|
||||
|
||||
def try_send_sms(self, *args, **kwargs):
|
||||
raise NotImplementedError('TODO Need to implement.')
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
raise NotImplementedError('TODO Need to implement.')
|
||||
def get_name(self):
|
||||
raise NotImplementedError("TODO Need to implement.")
|
||||
86
app/clients/sms/aws_sns.py
Normal file
86
app/clients/sms/aws_sns.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import re
|
||||
from time import monotonic
|
||||
|
||||
import boto3
|
||||
import botocore
|
||||
import phonenumbers
|
||||
|
||||
from app.clients.sms import SmsClient
|
||||
|
||||
|
||||
class AwsSnsClient(SmsClient):
|
||||
"""
|
||||
AwsSns sms client
|
||||
"""
|
||||
|
||||
def init_app(self, current_app, statsd_client, *args, **kwargs):
|
||||
self._client = boto3.client("sns", region_name=current_app.config["AWS_REGION"])
|
||||
self._long_codes_client = boto3.client("sns", region_name=current_app.config["AWS_PINPOINT_REGION"])
|
||||
super(SmsClient, self).__init__(*args, **kwargs)
|
||||
self.current_app = current_app
|
||||
self.statsd_client = statsd_client
|
||||
self.long_code_regex = re.compile(r"^\+1\d{10}$")
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return 'sns'
|
||||
|
||||
def send_sms(self, to, content, reference, sender=None, international=False):
|
||||
matched = False
|
||||
|
||||
for match in phonenumbers.PhoneNumberMatcher(to, "US"):
|
||||
matched = True
|
||||
to = phonenumbers.format_number(match.number, phonenumbers.PhoneNumberFormat.E164)
|
||||
|
||||
client = self._client
|
||||
# See documentation
|
||||
# https://docs.aws.amazon.com/sns/latest/dg/sms_publish-to-phone.html#sms_publish_sdk
|
||||
attributes = {
|
||||
"AWS.SNS.SMS.SMSType": {
|
||||
"DataType": "String",
|
||||
"StringValue": "Transactional",
|
||||
}
|
||||
}
|
||||
|
||||
# If sending with a long code number, we need to use another AWS region
|
||||
# and specify the phone number we want to use as the origination number
|
||||
send_with_dedicated_phone_number = self._send_with_dedicated_phone_number(sender)
|
||||
if send_with_dedicated_phone_number:
|
||||
client = self._long_codes_client
|
||||
attributes["AWS.MM.SMS.OriginationNumber"] = {
|
||||
"DataType": "String",
|
||||
"StringValue": sender,
|
||||
}
|
||||
|
||||
# If the number is US based, we must use a US Toll Free number to send the message
|
||||
country = phonenumbers.region_code_for_number(match.number)
|
||||
if country == "US":
|
||||
client = self._long_codes_client
|
||||
attributes["AWS.MM.SMS.OriginationNumber"] = {
|
||||
"DataType": "String",
|
||||
"StringValue": self.current_app.config["AWS_US_TOLL_FREE_NUMBER"],
|
||||
}
|
||||
|
||||
try:
|
||||
start_time = monotonic()
|
||||
response = client.publish(PhoneNumber=to, Message=content, MessageAttributes=attributes)
|
||||
except botocore.exceptions.ClientError as e:
|
||||
self.statsd_client.incr("clients.sns.error")
|
||||
raise str(e)
|
||||
except Exception as e:
|
||||
self.statsd_client.incr("clients.sns.error")
|
||||
raise str(e)
|
||||
finally:
|
||||
elapsed_time = monotonic() - start_time
|
||||
self.current_app.logger.info("AWS SNS request finished in {}".format(elapsed_time))
|
||||
self.statsd_client.timing("clients.sns.request-time", elapsed_time)
|
||||
self.statsd_client.incr("clients.sns.success")
|
||||
return response["MessageId"]
|
||||
|
||||
if not matched:
|
||||
self.statsd_client.incr("clients.sns.error")
|
||||
self.current_app.logger.error("No valid numbers found in {}".format(to))
|
||||
raise ValueError("No valid numbers found for SMS delivery")
|
||||
|
||||
def _send_with_dedicated_phone_number(self, sender):
|
||||
return sender and re.match(self.long_code_regex, sender)
|
||||
@@ -1,88 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from requests import RequestException, request
|
||||
|
||||
from app.clients.sms import SmsClient, SmsClientResponseException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Firetext will send a delivery receipt with three different status codes.
|
||||
# The `firetext_response` maps these codes to the notification statistics status and notification status.
|
||||
# If we get a pending (status = 2) delivery receipt followed by a declined (status = 1) delivery receipt we will set
|
||||
# the notification status to temporary-failure rather than permanent failure.
|
||||
# See the code in the notification_dao.update_notifications_status_by_id
|
||||
firetext_responses = {
|
||||
'0': 'delivered',
|
||||
'1': 'permanent-failure',
|
||||
'2': 'pending'
|
||||
}
|
||||
|
||||
firetext_codes = {
|
||||
# code '000' means 'No errors reported'
|
||||
'000': {'status': 'temporary-failure', 'reason': 'No error reported'},
|
||||
'101': {'status': 'permanent-failure', 'reason': 'Unknown Subscriber'},
|
||||
'102': {'status': 'temporary-failure', 'reason': 'Absent Subscriber'},
|
||||
'103': {'status': 'temporary-failure', 'reason': 'Subscriber Busy'},
|
||||
'104': {'status': 'temporary-failure', 'reason': 'No Subscriber Memory'},
|
||||
'201': {'status': 'permanent-failure', 'reason': 'Invalid Number'},
|
||||
'301': {'status': 'permanent-failure', 'reason': 'SMS Not Supported'},
|
||||
'302': {'status': 'temporary-failure', 'reason': 'SMS Not Supported'},
|
||||
'401': {'status': 'permanent-failure', 'reason': 'Message Rejected'},
|
||||
'900': {'status': 'temporary-failure', 'reason': 'Routing Error'},
|
||||
}
|
||||
|
||||
|
||||
def get_firetext_responses(status, detailed_status_code=None):
|
||||
detailed_status = firetext_codes[detailed_status_code]['reason'] if firetext_codes.get(
|
||||
detailed_status_code, None
|
||||
) else None
|
||||
return (firetext_responses[status], detailed_status)
|
||||
|
||||
|
||||
def get_message_status_and_reason_from_firetext_code(detailed_status_code):
|
||||
return firetext_codes[detailed_status_code]['status'], firetext_codes[detailed_status_code]['reason']
|
||||
|
||||
|
||||
class FiretextClient(SmsClient):
|
||||
'''
|
||||
FireText sms client.
|
||||
'''
|
||||
|
||||
def init_app(self, *args, **kwargs):
|
||||
super().init_app(*args, **kwargs)
|
||||
self.api_key = self.current_app.config.get('FIRETEXT_API_KEY')
|
||||
self.international_api_key = self.current_app.config.get('FIRETEXT_INTERNATIONAL_API_KEY')
|
||||
self.url = self.current_app.config.get('FIRETEXT_URL')
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return 'firetext'
|
||||
|
||||
def try_send_sms(self, to, content, reference, international, sender):
|
||||
data = {
|
||||
"apiKey": self.international_api_key if international else self.api_key,
|
||||
"from": sender,
|
||||
"to": to.replace('+', ''),
|
||||
"message": content,
|
||||
"reference": reference
|
||||
}
|
||||
|
||||
try:
|
||||
response = request(
|
||||
"POST",
|
||||
self.url,
|
||||
data=data,
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
json.loads(response.text)
|
||||
if response.json()['code'] != 0:
|
||||
raise ValueError("Expected 'code' to be '0'")
|
||||
except (ValueError, AttributeError):
|
||||
raise SmsClientResponseException("Invalid response JSON")
|
||||
except RequestException:
|
||||
raise SmsClientResponseException("Request failed")
|
||||
|
||||
return response
|
||||
@@ -1,110 +0,0 @@
|
||||
import json
|
||||
|
||||
from requests import RequestException, request
|
||||
|
||||
from app.clients.sms import SmsClient, SmsClientResponseException
|
||||
|
||||
mmg_response_map = {
|
||||
'2': {'status': 'permanent-failure', 'substatus': {
|
||||
"1": "Number does not exist",
|
||||
"4": "Rejected by operator",
|
||||
"5": "Unidentified Subscriber",
|
||||
"9": "Undelivered",
|
||||
"11": "Service for Subscriber suspended",
|
||||
"12": "Illegal equipment",
|
||||
"2049": "Subscriber IMSI blacklisted",
|
||||
"2050": "Number blacklisted in do-not-disturb blacklist",
|
||||
"2052": "Destination number blacklisted",
|
||||
"2053": "Source address blacklisted"
|
||||
}},
|
||||
'3': {'status': 'delivered', 'substatus': {"2": "Delivered to operator", "5": "Delivered to handset"}},
|
||||
'4': {'status': 'temporary-failure', 'substatus': {
|
||||
"6": "Absent Subscriber",
|
||||
"8": "Roaming not allowed",
|
||||
"13": "SMS Not Supported",
|
||||
"15": "Expired",
|
||||
"27": "Absent Subscriber",
|
||||
"29": "Invalid delivery report",
|
||||
"32": "Delivery Failure",
|
||||
}},
|
||||
'5': {'status': 'permanent-failure', 'substatus': {
|
||||
"6": "Network out of coverage",
|
||||
"8": "Incorrect number prefix",
|
||||
"10": "Number on do-not-disturb service",
|
||||
"11": "Sender id not registered",
|
||||
"13": "Sender id blacklisted",
|
||||
"14": "Destination number blacklisted",
|
||||
"19": "Routing unavailable",
|
||||
"20": "Rejected by anti-flooding mechanism",
|
||||
"21": "System error", # it says to retry those messages or contact support
|
||||
"23": "Duplicate message id",
|
||||
"24": "Message formatted incorrectly",
|
||||
"25": "Message too long",
|
||||
"51": "Missing recipient value",
|
||||
"52": "Invalid destination",
|
||||
}},
|
||||
}
|
||||
|
||||
|
||||
def get_mmg_responses(status, detailed_status_code=None):
|
||||
return (mmg_response_map[status]["status"], mmg_response_map[status]["substatus"].get(detailed_status_code, None))
|
||||
|
||||
|
||||
class MMGClientResponseException(SmsClientResponseException):
|
||||
def __init__(self, response, exception):
|
||||
status_code = response.status_code if response is not None else 504
|
||||
text = response.text if response is not None else "Gateway Time-out"
|
||||
|
||||
self.status_code = status_code
|
||||
self.text = text
|
||||
self.exception = exception
|
||||
|
||||
def __str__(self):
|
||||
return "Code {} text {} exception {}".format(self.status_code, self.text, str(self.exception))
|
||||
|
||||
|
||||
class MMGClient(SmsClient):
|
||||
'''
|
||||
MMG sms client
|
||||
'''
|
||||
|
||||
def init_app(self, *args, **kwargs):
|
||||
super().init_app(*args, **kwargs)
|
||||
self.api_key = self.current_app.config.get('MMG_API_KEY')
|
||||
self.mmg_url = self.current_app.config.get('MMG_URL')
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return 'mmg'
|
||||
|
||||
def try_send_sms(self, to, content, reference, international, sender):
|
||||
data = {
|
||||
"reqType": "BULK",
|
||||
"MSISDN": to,
|
||||
"msg": content,
|
||||
"sender": sender,
|
||||
"cid": reference,
|
||||
"multi": True
|
||||
}
|
||||
|
||||
try:
|
||||
response = request(
|
||||
"POST",
|
||||
self.mmg_url,
|
||||
data=json.dumps(data),
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Basic {}'.format(self.api_key)
|
||||
},
|
||||
timeout=60
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
try:
|
||||
json.loads(response.text)
|
||||
except (ValueError, AttributeError):
|
||||
raise SmsClientResponseException("Invalid response JSON")
|
||||
except RequestException:
|
||||
raise SmsClientResponseException("Request failed")
|
||||
|
||||
return response
|
||||
Reference in New Issue
Block a user