{ "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": "*" } ] }
PartitionKey
.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
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): # TODO implement return { 'statusCode': 200, 'body': json.dumps('Hello from Lambda!') }
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}
lambda_handler
.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: 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)
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)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): # TODO implement return { 'statusCode': 200, 'body': json.dumps('Hello from Lambda!') }
requests
library.pip
support (you can very easily bundle a zip to upload, but that's a separate tutorial), so let's just do this using native Python.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"))))
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: no_winner_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) def _process_winners(winners): winner_msg = "You won the raffle! WHAT ARE THE ODDS?! Well, 1 in {}, to be exact, you lucky duck!".format(len(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)
lambda_function
, which is simply going to validate and iterate over the entries in the DyanmoDB table and choose NUM_WINNERS
at random.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 = _choose_winners(no_winner_response["Items"], winner_nex_fe) logger.info("Chose {} winner(s), updating table and messaging them now.".format(len(winners))) _process_winners(winners) return { "statusCode": 200 }
application/json
(which is API Gateway's default) for the request/response. As such, you'll have to forgive me for ensuring this paragraph contains the maximum number of XML-based keywords so others will stumble upon this solution—figuring out how to use API Gateway for non-JSON requests ended up being more of a chore than I thought it would be.application/json
content. When Twilio POST
s to this endpoint, it'll do so with a content-type of application/x-www-form-urlencoded
, and it'll expect a content-type of application/xml
in the response. But Lambda expects that the events it consumes will be JSON. To make this work, we're going to take the URL encoded query parameters Twilio gives us and stuff them in to a "body-json" parameter in the event we'll send to Lambda.application/x-www-form-urlencoded
using the "General template" of "Method Request Passthrough".application/xml
.application/xml
with the following template:#set($inputRoot = $input.path('$')) $inputRoot.body