X-Git-Url: https://git.cworth.org/git?a=blobdiff_plain;f=turbot_lambda%2Fturbot_lambda.py;h=d529c85ed625a1846d3dd7e72703de9ecc912676;hb=192c9ca74b84a807c7f962a5ee6dd5c2d3c2759b;hp=b86d9184921c9a61fb67a4b1b89a97d4f308dd0e;hpb=ceeec94475d9d49f1b5d3091bc6e7889f1162bbf;p=turbot diff --git a/turbot_lambda/turbot_lambda.py b/turbot_lambda/turbot_lambda.py index b86d918..d529c85 100644 --- a/turbot_lambda/turbot_lambda.py +++ b/turbot_lambda/turbot_lambda.py @@ -1,20 +1,77 @@ from urllib.parse import parse_qs -from turbot.rot import rot from slack import WebClient +import base64 import boto3 import requests -import hashlib -import hmac +import json +import pickle +import os +from types import SimpleNamespace +from google.auth.transport.requests import Request +from googleapiclient.discovery import build + +import turbot.interaction +import turbot.events ssm = boto3.client('ssm') -response = ssm.get_parameter(Name='SLACK_SIGNING_SECRET', WithDecryption=True) -slack_signing_secret = bytes(response['Parameter']['Value'], 'utf-8') +# Note: Late import here to have the environment variable above available +from turbot.slack import slack_is_valid_request # noqa -response = ssm.get_parameter(Name='SLACK_BOT_TOKEN', WithDecryption=True) -slack_bot_token = response['Parameter']['Value'] +if 'SLACK_BOT_TOKEN' in os.environ: + slack_bot_token = os.environ['SLACK_BOT_TOKEN'] +else: + response = ssm.get_parameter(Name='SLACK_BOT_TOKEN', WithDecryption=True) + slack_bot_token = response['Parameter']['Value'] + os.environ['SLACK_BOT_TOKEN'] = slack_bot_token slack_client = WebClient(slack_bot_token) +if 'GSHEETS_PICKLE_BASE64' in os.environ: + gsheets_pick_base64 = os.environ['GSHEETS_PICKLE_BASE64'] +else: + response = ssm.get_parameter(Name='GSHEETS_PICKLE_BASE64', + WithDecryption=True) + gsheets_pickle_base64 = response['Parameter']['Value'] + os.environ['GSHEETS_PICKLE_BASE64'] = gsheets_pickle_base64 +gsheets_pickle = base64.b64decode(gsheets_pickle_base64) +gsheets_creds = pickle.loads(gsheets_pickle) + +if gsheets_creds: + if gsheets_creds.valid: + print("Loaded valid GSheets credentials from SSM") + else: + gsheets_creds.refresh(Request()) + gsheets_pickle = pickle.dumps(gsheets_creds) + gsheets_pickle_base64_bytes = base64.b64encode(gsheets_pickle) + gsheets_pickle_base64 = gsheets_pickle_base64_bytes.decode('us-ascii') + print("Storing refreshed GSheets credentials into SSM") + os.environ['GSHEETS_PICKLE_BASE64'] = gsheets_pickle_base64 + ssm.put_parameter(Name='GSHEETS_PICKLE_BASE64', + Type='SecureString', + Value=gsheets_pickle_base64, + Overwrite=True) +service = build('sheets', + 'v4', + credentials=gsheets_creds, + cache_discovery=False) +sheets = service.spreadsheets() +service = build('drive', + 'v3', + credentials=gsheets_creds, + cache_discovery=False) +files = service.files() +permissions = service.permissions() + +db = boto3.resource('dynamodb') + +turb = SimpleNamespace() +turb.slack_client = slack_client +turb.db = db +turb.table = db.Table("turbot") +turb.sheets = sheets +turb.files = files +turb.permissions = permissions + def error(message): """Generate an error response for a Slack request @@ -29,82 +86,187 @@ def error(message): 'body': '' } -def slack_is_valid_request(slack_signature, timestamp, body): - """Returns True if the timestamp and body correspond to signature. +def turbot_lambda(event, context): + """Top-level entry point for our lambda function. + + This can handle either a REST API request from Slack, or an HTTP + request for teh Turbot web view + """ - This implements the Slack signature verification using the slack - signing secret (obtained via an SSM parameter in code above).""" + # First, determine if we've been invoked by Slack, (by presence of + # the X-Slack-Signature header) + headers = requests.structures.CaseInsensitiveDict(event['headers']) - content = "v0:{}:{}".format(timestamp,body).encode('utf-8') + if 'X-Slack-Signature' in headers: + return turbot_slack_handler(event, context) - signature = 'v0=' + hmac.new(slack_signing_secret, - content, - hashlib.sha256).hexdigest() + # Otherwise, emit the Turbot web view + return turbot_web_view(event, context) - if hmac.compare_digest(signature, slack_signature): - return True - else: - print("Bad signature: {} != {}".format(signature, slack_signature)) - return False +def turbot_web_view(event, context): + """Turbot web view -def turbot_lambda(event, context): - """Top-level entry point for our lambda function. + """ + + return { + 'statusCode': '200', + 'body': 'Hello, Lambda world.', + 'headers': { + 'Content-Type': 'application/text', + }, + } + +def turbot_slack_handler(event, context): + """Primary entry point for all Slack-initiated API requests to Turbot This function first verifies that the request actually came from Slack, (by means of the SLACK_SIGNING_SECRET SSM parameter), and refuses to do anything if not. - Then this parses the request and arguments and farms out to - supporting functions to implement all supported slash commands. + Then this defers to either turbot_event_handler or + turbot_slash_command to do any real work. """ - signature = event['headers']['X-Slack-Signature'] - timestamp = event['headers']['X-Slack-Request-Timestamp'] + headers = requests.structures.CaseInsensitiveDict(event['headers']) + + signature = headers['X-Slack-Signature'] + timestamp = headers['X-Slack-Request-Timestamp'] if not slack_is_valid_request(signature, timestamp, event['body']): return error("Invalid Slack signature") + # It's a bit cheesy, but we'll just use the content-type header to + # determine if we're being called from a Slack event or from a + # slash command or other interactivity. (The more typical way to + # do this would be to have different URLs for each Slack entry + # point, but it's simpler to have our Slack app implemented as a + # single AWS Lambda, (which can only have a single entry point). + content_type = headers['content-type'] + + if (content_type == "application/json"): + return turbot_event_handler(turb, event, context) + if (content_type == "application/x-www-form-urlencoded"): + return turbot_interactive_or_slash_command(turb, event, context) + return error("Unknown content-type: {}".format(content_type)) + +def turbot_event_handler(turb, event, context): + """Handler for all subscribed Slack events""" + + body = json.loads(event['body']) + + type = body['type'] + + if type == 'url_verification': + return url_verification_handler(turb, body) + if type == 'event_callback': + return event_callback_handler(turb, body) + return error("Unknown event type: {}".format(type)) + +def url_verification_handler(turb, body): + + # First, we have to properly respond to url_verification + # challenges or else Slack won't let us configure our URL as an + # event handler. + challenge = body['challenge'] + + return { + 'statusCode': 200, + 'body': challenge + } + +def event_callback_handler(turb, body): + event = body['event'] + type = event['type'] + + if type in turbot.events.events: + return turbot.events.events[type](turb, event) + return error("Unknown event type: {}".format(type)) + +def turbot_interactive_or_slash_command(turb, event, context): + """Handler for Slack interactive things (buttons, shortcuts, etc.) + as well as slash commands. + + This function simply makes a quick determination of what we're looking + at and then defers to either turbot_interactive or turbot_slash_command.""" + + # Both interactives and slash commands have a urlencoded body body = parse_qs(event['body']) - command = body['command'][0] - args = body['text'][0] - if (command == "/rotlambda" or command == "/rot"): - return rot_slash_command(body, args) + # The difference is that an interactive thingy has a 'payload' + # while a slash command has a 'command' + if 'payload' in body: + return turbot_interactive(turb, json.loads(body['payload'][0])) + if 'command' in body: + return turbot_slash_command(turb, body) + return error("Unrecognized event (neither interactive nor slash command)") - return error("Command {} not implemented".format(command)) +def turbot_interactive(turb, payload): + """Handler for Slack interactive requests + + These are the things that come from a user interacting with a button + a shortcut or some other interactive element that our app has made + available to the user.""" -def rot_slash_command(body, args): - """Implementation of the /rot command + type = payload['type'] - The args string should be as follows: + if type == 'block_actions': + return turbot_block_action(turb, payload) + if type == 'view_submission': + return turbot.interaction.view_submission(turb, payload) + if type == 'shortcut': + return turbot_shortcut(turb, payload) + return error("Unrecognized interactive type: {}".format(type)) - [count|*] String to be rotated +def turbot_block_action(turb, payload): + """Handler for Slack interactive block actions - That is, the first word of the string is an optional number (or - the character '*'). If this is a number it indicates an amount to - rotate each character in the string. If the count is '*' or is not - present, then the string will be rotated through all possible 25 - values. + Specifically, those that have a payload type of 'block_actions'""" - The result of the rotation is returned (with Slack formatting) in - the body of the response so that Slack will provide it as a reply - to the user who submitted the slash command.""" + actions = payload['actions'] - channel_name = body['channel_name'][0] - response_url = body['response_url'][0] - channel_id = body['channel_id'][0] + if len(actions) != 1: + return error("No support for multiple actions ({}) in a single request" + .format(len(actions))) - result = rot(args) + action = actions[0] - if (channel_name == "directmessage"): - requests.post(response_url, - json = {"text": result}, - headers = {"Content-type": "application/json"}) + atype = action['type'] + if 'value' in action: + avalue = action['value'] else: - slack_client.chat_postMessage(channel=channel_id, text=result) + avalue = '*' - return { - 'statusCode': 200, - 'body': "" - } + if ( + atype in turbot.interaction.actions + and avalue in turbot.interaction.actions[atype] + ): + return turbot.interaction.actions[atype][avalue](turb, payload) + return error("Unknown action of type/value: {}/{}".format(atype, avalue)) + +def turbot_shortcut(turb, payload): + """Handler for Slack shortcuts + + These are invoked as either global or message shortcuts by a user.""" + + print("In turbot_shortcut, payload is: {}".format(str(payload))) + + return error("Shortcut interactions not yet implemented") + +def turbot_slash_command(turb, body): + """Implementation for Slack slash commands. + + This parses the request and arguments and farms out to + supporting functions to implement all supported slash commands. + """ + + command = body['command'][0] + if 'text' in body: + args = body['text'][0] + else: + args = '' + + if command in turbot.interaction.commands: + return turbot.interaction.commands[command](turb, body, args) + + return error("Command {} not implemented".format(command))