Skip to content

Instantly share code, notes, and snippets.

@alexdlaird
Last active September 11, 2020 00:47
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save alexdlaird/d6fee4e30aa32128260a664141bd89bf to your computer and use it in GitHub Desktop.
Save alexdlaird/d6fee4e30aa32128260a664141bd89bf to your computer and use it in GitHub Desktop.
Serverless SMS raffle powered by Twilio and AWS

Serverless SMS Raffle

Create a new Role from a Policy with the following permissions:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents",
                "dynamodb:CreateTable",
                "dynamodb:GetItem",
                "dynamodb:PutItem",
                "dynamodb:UpdateItem",
                "dynamodb:DescribeTable",
                "dynamodb:GetShardIterator",
                "dynamodb:GetRecords",
                "dynamodb:ListStreams",
                "dynamodb:Query",
                "dynamodb:Scan"
            ],
            "Resource": "*"
        }
    ]
}

In AWS, create a DynamoDB table with a partition key of PartitionKey.

In AWS, create a Lambda (using the new Role) from file #2.

For file #2, add the following environment variables to the Lambda:

  • SUPER_SECRET_PASSPHRASE (some phrase people must text in order to be entered in to the raffle)
  • BANNED_NUMBERS (JSON list of phone numbers formatted ["+15555555555","+15555555556"])
  • DYNAMODB_REGION (like us-east-1)
  • DYNAMODB_ENDPOINT (like https://dynamodb.us-east-1.amazonaws.com)
  • DYNAMODB_TABLE

In AWS, create a Lambda (using the new Role) from file #3.

For file #3, add the following environment variables to the Lambda:

  • DYNAMODB_REGION (like us-east-1)
  • DYNAMODB_ENDPOINT (like https://dynamodb.us-east-1.amazonaws.com)
  • DYNAMODB_TABLE
  • TWILIO_ACCOUNT_SID
  • TWILIO_AUTH_TOKEN
  • TWILIO_SMS_FROM (Twilio phone number formatted +15555555555)
  • NUM_WINNERS (defaults to "10" if not set)

In AWS, create a new API Gateway. In the API, create the following:

  • Create a new "POST" method with the "Integration type" of "Lambda Function" and point it to the Lambda for file #2
    • Edit the "POST" method's "Integration Request"
      • Under "Mapping Templates", add a "Content-Type" of application/x-www-form-urlencoded using the "General template" of "Method Request Passthrough"
    • Edit the "POST" method's "Method Response"
      • Edit the 200 response so it has a "Content type" of application/xml

Last, under the "POST" method's "Integration Response", edit the 200 response. Under "Mapping Templates" of "Content-Type" of application/xml with the following template:

#set($inputRoot = $input.path('$'))
$inputRoot.body

Deploy the new API Gateway.

In Twilio, create a phone number and set it up. Under "Messaging", select "Webhook" for when "A Message Comes In", select "POST", and enter the deployed API Gateway URL.

Users can now text the super secret passphrase to the phone number to be entered in to the raffle. When you are ready to close the raffle and have the winners randomly chosen, manually execute the Lambda created for file #3, which will close the raffle and text the winners.

A list of entries, with winners marked accordingly, can be found by viewing the items in the DynamoDB table for the raffle.

import os
import json
import logging
import boto3
from urllib.parse import parse_qs
SUPER_SECRET_PASSPHRASE = os.environ.get("SUPER_SECRET_PASSPHRASE", "Ahoy")
BANNED_NUMBERS = json.loads(os.environ.get("BANNED_NUMBERS", "[]"))
DYNAMODB_REGION = os.environ.get("DYNAMODB_REGION")
DYNAMODB_ENDPOINT = os.environ.get("DYNAMODB_ENDPOINT")
DYNAMODB_TABLE = os.environ.get("DYNAMODB_TABLE")
logger = logging.getLogger()
logger.setLevel(logging.INFO)
dynamodb = boto3.resource("dynamodb", region_name=DYNAMODB_REGION, endpoint_url=DYNAMODB_ENDPOINT)
table = dynamodb.Table(DYNAMODB_TABLE)
def lambda_handler(event, context):
logger.info("Event: {}".format(event))
data = parse_qs(event["body-json"])
phone_number = data["From"][0]
body = data["Body"][0]
logger.info("Received '{}' from {}".format(body, phone_number))
if body.lower().strip() != SUPER_SECRET_PASSPHRASE.lower():
return _get_response("Hmm. That's not the right entry word for the raffle.")
# If the raffle has already been closed (i.e. the Lambda to choose the winners has already been run),
# no longer accept new entries
if _is_raffle_closed(table):
return _get_response("Sorry, this raffle has closed.")
# Shame the people who know they aren't allowed to enter the raffle but try to anyway
if _is_karen(phone_number):
return _get_response("Nice try, Karen. You know you're not allowed to enter the raffle.")
db_read_response = table.get_item(
Key={
"PartitionKey": "PhoneNumber:{}".format(phone_number)
}
)
logger.info("DyanmoDB read response: {}".format(db_read_response))
if "Item" in db_read_response:
logger.info("Number has already entered raffle")
response_msg = "Cheater. You can only enter the raffle once. This incident has been reported to the proper authorities."
else:
db_write_response = table.put_item(
Item={
"PartitionKey": "PhoneNumber:{}".format(phone_number)
}
)
logger.info("DyanmoDB write response: {}".format(db_write_response))
response_msg = "Boomsauce, your number has been entered in to the raffle. Good luck!"
return _get_response(response_msg)
def _is_raffle_closed(table):
winner_response = table.scan(
FilterExpression=boto3.dynamodb.conditions.Attr("Winner").exists()
)
return winner_response["Count"] > 0
def _is_karen(phone_number):
return phone_number in BANNED_NUMBERS
def _get_response(msg):
xml_response = "<?xml version='1.0' encoding='UTF-8'?><Response><Message>{}</Message></Response>".format(msg)
logger.info("XML response: {}".format(xml_response))
return {"body": xml_response}
import os
import logging
import boto3
import random
import base64
from urllib import request, parse
DYNAMODB_REGION = os.environ.get("DYNAMODB_REGION")
DYNAMODB_ENDPOINT = os.environ.get("DYNAMODB_ENDPOINT")
DYNAMODB_TABLE = os.environ.get("DYNAMODB_TABLE")
TWILIO_ACCOUNT_SID = os.environ.get("TWILIO_ACCOUNT_SID")
TWILIO_AUTH_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN")
TWILIO_SMS_URL = "https://api.twilio.com/2010-04-01/Accounts/{}/Messages.json"
# This should be the same Twilio number that users are texting to be entered in to the raffle
TWILIO_SMS_FROM = os.environ.get("TWILIO_SMS_FROM")
# If the number of entries ends up being less than this number, this Lambda will not run
NUM_WINNERS = os.environ.get("NUM_WINNERS", 10)
logger = logging.getLogger()
logger.setLevel(logging.INFO)
dynamodb = boto3.resource("dynamodb", region_name=DYNAMODB_REGION, endpoint_url=DYNAMODB_ENDPOINT)
table = dynamodb.Table(DYNAMODB_TABLE)
def lambda_handler(event, context):
logger.info("Event: {}".format(event))
winner_nex_fe = boto3.dynamodb.conditions.Attr("Winner").not_exists()
winner_ex_fe = boto3.dynamodb.conditions.Attr("Winner").exists()
# These queries are ugly and inefficient, but all we're really trying to do here is check if winners
# have already been chosen, because if they have, the raffle is closed and the Lambda shouldn't run
winner_response = table.scan(
FilterExpression=winner_ex_fe
)
no_winner_response = table.scan(
FilterExpression=winner_nex_fe
)
if winner_response["Count"] == 0 and no_winner_response["Count"] == 0:
logger.info("Nothing to do, the DyanmoDB table {} does not yet have any entries.".format(DYNAMODB_TABLE))
return {
"statusCode": 204
}
elif winner_response["Count"] > 0:
logger.info("Nothing to do, as the DynamoDB table {} has already had raffle winners processed.".format(DYNAMODB_TABLE))
return {
"statusCode": 204
}
elif no_winner_response["Count"] < NUM_WINNERS:
logger.info("Uh-oh, it doesn't look like we have enough raffle entries to choose {} randomly.".format(NUM_WINNERS))
return {
"statusCode": 400
}
winners, num_entries = _choose_winners(no_winner_response, winner_nex_fe)
logger.info("Chose {} winner(s), updating table and messaging them now.".format(len(winners)))
_process_winners(winners, num_entries)
return {
"statusCode": 200
}
def _choose_winners(eligible_response, fe):
# Process as many entries as are given to us in the first response (it may be all of them)
entries = set(i["PartitionKey"] for i in eligible_response["Items"])
# If Dynamo gave us a pageable response, it means there are more items that matched our scan,
# so rinse and repeat before choosing the winners
while "LastEvaluatedKey" in eligible_response:
eligible_response = table.scan(
FilterExpression=fe
)
entries.union(set(i["PartitionKey"] for i in eligible_response["Items"]))
# Select a random sample of size NUM_WINNERS from the list of entries
return random.sample(list(entries), NUM_WINNERS), len(entries)
def _process_winners(winners, num_entries):
winner_msg = "You won the raffle! WHAT ARE THE ODDS?! Well, 1 in {}, to be exact, you lucky duck!".format(num_entries)
# Update the record for each winner, and send a text message informing them of their good fortune
for winner in winners:
winner_phone_number = winner[len("PhoneNumber") + 1:]
updated_response = table.update_item(
Key={
"PartitionKey": winner
},
UpdateExpression="set Winner = :w",
ExpressionAttributeValues={
":w": "true"
},
ReturnValues="UPDATED_NEW"
)
logger.info("DynamoDB updated response: {}".format(updated_response))
_send_sms(winner_phone_number, winner_msg)
def _send_sms(to_number, msg):
populated_url = TWILIO_SMS_URL.format(TWILIO_ACCOUNT_SID)
post_params = {"To": to_number, "From": TWILIO_SMS_FROM, "Body": msg}
data = parse.urlencode(post_params).encode()
req = request.Request(populated_url)
authentication = "{}:{}".format(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
base64string = base64.b64encode(authentication.encode("utf-8"))
req.add_header("Authorization", "Basic %s" % base64string.decode("ascii"))
with request.urlopen(req, data) as f:
logger.info("Twilio returned {}".format(str(f.read().decode("utf-8"))))
{
"body-json": "ToCountry=US&ToState=DC&SmsMessageSid=SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&NumMedia=0&ToCity=&FromZip=&SmsSid=SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&FromState=CA&SmsStatus=received&FromCity=&Body=Ahoy&FromCountry=US&To=%2B15555555555&ToZip=&AddOns=%7B%22status%22%3A%22successful%22%2C%22message%22%3Anull%2C%22code%22%3Anull%2C%22results%22%3A%7B%22twilio_caller_name%22%3A%7B%22request_sid%22%3A%XXX%22%2C%22status%22%3A%22successful%22%2C%22message%22%3Anull%2C%22code%22%3Anull%2C%22result%22%3A%7B%22caller_name%22%3A%7B%22caller_name%22%3A%22LAIRD+ALEX%22%2C%22caller_type%22%3A%22CONSUMER%22%2C%22error_code%22%3Anull%7D%2C%22phone_number%22%3A%22%2B15555555555%22%7D%7D%2C%22twilio_carrier_info%22%3A%7B%22request_sid%22%3A%XXX%22%2C%22status%22%3A%22successful%22%2C%22message%22%3Anull%2C%22code%22%3Anull%2C%22result%22%3A%7B%22phone_number%22%3A%22%2B15555555555%22%2C%22carrier%22%3A%7B%22mobile_country_code%22%3A%22310%22%2C%22mobile_network_code%22%3A%22160%22%2C%22name%22%3A%22T-Mobile+USA%2C+Inc.%22%2C%22type%22%3A%22mobile%22%2C%22error_code%22%3Anull%7D%7D%7D%7D%7D&NumSegments=1&MessageSid=SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&AccountSid=ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&From=%2B15555555555&ApiVersion=2010-04-01",
"params": {
"path": {},
"querystring": {},
"header": {
"Accept": "*/*",
"Cache-Control": "max-age=259200",
"Content-Type": "application/x-www-form-urlencoded",
"Host": "XXX.execute-api.us-east-1.amazonaws.com",
"User-Agent": "TwilioProxy/1.1",
"X-Amzn-Trace-Id": "Root=XXX",
"X-Forwarded-For": "10.10.10.10",
"X-Forwarded-Port": "443",
"X-Forwarded-Proto": "https",
"X-Twilio-Signature": "XXX="
}
},
"stage-variables": {},
"context": {
"account-id": "",
"api-id": "XXX",
"api-key": "",
"authorizer-principal-id": "",
"caller": "",
"cognito-authentication-provider": "",
"cognito-authentication-type": "",
"cognito-identity-id": "",
"cognito-identity-pool-id": "",
"http-method": "POST",
"stage": "prod",
"source-ip": "10.10.10.10",
"user": "",
"user-agent": "TwilioProxy/1.1",
"user-arn": "",
"request-id": "XXX",
"resource-id": "XXX",
"resource-path": "/inbound"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment