Commit 96bdc037 authored by Jonah Meijers's avatar Jonah Meijers

Initial commit

parents
# Creating a slack slash-command that functions as a quote-bot
## Introduction
When you're in an active slack-community quite often someone says something funny, or otherwise noteworthy.
'Back in the old days or IRC' having bots in channels that performed all kind of functions were standard. Some of them
even contained functionality to save and retrieve quotes.
Nowadays I found this lacking, and decided to build this using the 'serverless' architecture.
By leveraging AWS technologies like Lambda, API-Gateway and DynamoDB, I found it was possible to re-create the old-school quote bots.
In this repository you will find a bot-lambda.py file which contains the code for a lambda which performs this role.
## Prerequisites
To be able to use this lambda you will need a few things in AWS set-up.
### DynamoDB Structure
In dynamodb create the following table.
| quote-table | Field name | Field Type |
|-------------|-------------|-----------------------|
| | channel | Primary partition key |
| | quote-index | Primary sort key |
| | quote | Regular field |
| | | |
### Create Lambda
Create a lambda and pick the 'slack-echo-command-python' blueprint and use most of the defaults.
Note: Your lambda will need to use an api-gateway and use a role that has access to DynamoDB.
Use the code in the repository as your function.
### Create a slack 'Slash-command'
Go to <yourcommunityname>.slack.com/apps/manage/custom-integrations, create your slash-command and point it to the endpoint of your api-gateway. It will show you the token it will use to authenticate. Insert this into the code of your lambda function.
## How does it work?
DynamoDB is an advanced highly-scalable key-value store developed by Amazon to allow for high-volume high-performance datastorage.
It is a no-sql database.
Best practices dictate that your partition key is unique and evenly distributed so it can create a homogenous partitioning of your data to different nodes if the system has the need for it. By design DynamoDB does not offer strongly consistent reads- and writes. This allows DynamoDB to scale the load over multiple nodes and achieve high throughput. This means that things like auto_increments are not possible from the perspective of the database.
By breaking with these best-practices and taking the performance penalty that comes along with it. It is possible to programatically have things like an auto_increment as a 'primary' id. Check the bot-lambda.py for an example.
It is possible to create a 'virtual' auto_increment, by finding the highest index already present in the database, and incrementing it with one. Finding the highest index is possible without scanning the complete dataset for values.
This is done by using the index as a sort field, and querying the table. In this query we tell the database we want to sort the dataset descending on this key, and limit the result to a single record.
Please note, that there is a very probable race condition here with large amounts of parallel writes, this code is only meant as an example of this concept..
'''
This function handles a Slack slash command and echoes the details back to the user.
Follow these steps to configure the slash command in Slack:
1. Navigate to https://<your-team-domain>.slack.com/services/new
2. Search for and select "Slash Commands".
3. Enter a name for your command and click "Add Slash Command Integration".
4. Copy the token string from the integration settings and use it in the next section.
5. After you complete this blueprint, enter the provided API endpoint URL in the URL field.
'''
import json
import logging
from random import randint
from urlparse import parse_qs
import boto3
from boto3.dynamodb.conditions import Key
expected_token = 'THE TOKEN BEING SENT BY SLACK'
#This is not really being used except for having a set partition key
channel_identifier = '#SOMESLACKCHANNEL'
quote_table_name = 'quote-table'
quote_page_size = 10
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def respond(err, res=None):
"""
Returns a dict corresponding with the way Slack expects it
Args:
err (error): An error if there is one
res (string): The string to encode to send as payload
Returns:
dict: A dict to send to slack
"""
return {
'statusCode': '400' if err else '200',
'body': err.message if err else json.dumps(res),
'headers': {
'Content-Type': 'application/json',
},
}
def extract_quote_command(input_text):
"""
Some basic string manipulating to figure out what the user wants to do
Args:
input_text(string): text to extract the 'command' from
Returns:
string: the command to be used for routing
"""
return input_text.partition(' ')[0]
def extract_quote_params(input_text):
"""
Grab the params from the string
Args:
input_text(string): The string to extract the params from (the rest)
Returns:
string: Params send after the command, if none found returns empty string
"""
return_value = ''
if len(input_text.split(' ', 1)) > 1:
return_value += input_text.split(' ', 1)[1]
return return_value
def get_highest_index():
"""
This is an ugly hack, and nifty trick at the same time
dynamodb by design doesn't really do things like an auto-increment
This makes use of a ConsistentRead, a limit of 1 and a descending sort
of the sort-key to reliably get the highest value for the index.
This only works because we work with a 'fixed' partition-key and abuse
the sort-key as a 'virtual' primary key
Note: This might break for high-scale operations and allow for race-conditions.
Returns:
int: The 'highest' index for the quote-index sort-key
"""
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(quote_table_name)
response = table.query(
Limit=1,
ScanIndexForward=False,
ConsistentRead=True,
KeyConditionExpression=Key('channel').eq(channel_identifier),
ExpressionAttributeValues={':channel' : channel_identifier}
)
max_value = response['Items'][0]['quote-index']
return max_value
def get_quote(quote_params):
"""
Does a simple lookup of a quote per its quote-index (id)
Args:
quote_params(string):
"""
#this is dangerous, add some form of validation here.
quote_index = quote_params
return_value = ''
#Hack, to make sure there is input
if len(str(quote_index)) > 0:
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(quote_table_name)
response = table.get_item(Key={'channel' : channel_identifier,
'quote-index' : int(quote_index)
})
return_value = response['Item']['quote']
else:
return_value = 'No index given, not returning quote.'
return return_value
def get_random_quote():
"""
Generates a random id, gets the quote and returns it
Returns:
string: a random quote
"""
max_index = get_highest_index()
random_index = randint(0, max_index)
return get_quote(random_index)
def list_quotes(quote_params):
"""
Returns quotes from descending from the newest, allows for some pagination
Args:
quote_params(string): gets casted to an int if possible for page selection
Returns:
string: A string with a list of quotes to send to Slack
"""
quote_list_string = ''
highest_index = get_highest_index()
if len(quote_params):
page = int(quote_params)
else:
page = 1
range_min = highest_index - (page*quote_page_size)
range_max = range_min + quote_page_size
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(quote_table_name)
response = table.query(
Limit=quote_page_size,
ScanIndexForward=False,
ConsistentRead=True,
KeyConditionExpression=Key('channel').eq(channel_identifier) & Key('quote-index').between(range_min, range_max))
quote_list_string = 'Listing quotes: \n'
for quote_item in response['Items']:
quote_list_string += '#' + str(quote_item['quote-index'])
quote_list_string += ' -> ' + quote_item['quote'] + '\n'
return quote_list_string
def insert_quote(quote):
"""
This method is used to insert new quotes,
WARNING: Since there is no table locking it is possible to write 2 quotes
with the same quote-index, unless implementing something that mitigates
race-conditions, this poses a significant risk.
This is only supposed to be used as an example and NOT in a real-world
scenario.
(For a low-traffic slack community this works fine though.)
Throttling write operations can also prevent race-conditions.
Args:
quote(string): The quote to add to the quotedb
Returns:
int: index of the quote that was added
"""
new_quote_index = get_highest_index()+1
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(quote_table_name)
response = table.put_item(
Item={
'channel' : channel_identifier,
'quote-index' : new_quote_index,
'quote' : quote
}
)
return new_quote_index
def handle_quote_input(command, quote_params):
"""
Simple command routing
Args:
command(string): Command to decide what method to use
quote_params(string): params to pass to the method if applicable
Returns:
string: String to output to slack as feedback to the user.
;
"""
return_value = ''
if command == 'add':
return_value = "New quote added, returned index: " + str(insert_quote(quote_params))
elif command == 'count':
return_value = "There are: " + str(get_highest_index()+1) + " quotes in the system."
elif command == 'get':
return_value = str(get_quote(quote_params))
elif command == 'random':
return_value = str(get_random_quote())
elif command == 'list':
return_value = str(list_quotes(quote_params))
else:
return_value = "No valid command received, printing random quote: \n"
return_value += get_random_quote()
return return_value
def lambda_handler(event, context):
"""
This lambda_handler is taken directly from the slack blueprint for a lambda
Args:
event(dict): Event containing the params body
context: Currently not being used anywhere, but is passed by the lambda
Returns:
dict: a dict generated by the response mnethod
"""
params = parse_qs(event['body'])
token = params['token'][0]
if token != expected_token:
logger.error("Request token (%s) does not match expected", token)
return respond(Exception('Invalid request token'))
user = params['user_name'][0]
command = params['command'][0]
channel = params['channel_name'][0]
if 'text' in params:
command_text = params['text'][0]
else:
command_text = ''
#Start parsing command text, extract command and other params
quote_command = extract_quote_command(command_text)
command_params = extract_quote_params(command_text)
return_body = handle_quote_input(quote_command, command_params)
return_dict = {}
return_dict['response_type'] = 'in_channel'
return_dict['text'] = return_body
return respond(None, return_dict)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment