From 41931beb0de72a0669600e92dfe4d00b2e596dbf Mon Sep 17 00:00:00 2001 From: Carl Worth Date: Wed, 21 Oct 2020 16:12:42 -0700 Subject: [PATCH] Implement a new `/puzzle` slash command to create a puzzle This command doesn't (yet) accept any arguments. Instead, it pops up a modal dialog to prompt for the puzzle name, ID, and URL. The command also only works from a top-level hunt channel. Some things that would be nice improvements here: * Auto-fill the puzzle ID based on the puzzle name without punctuation and spaces replaced with underscores. * Allow the command to work from the channel of any puzzle in the hunt * Fix the bug that the modal dialog reports an error even when things work correctly, (the problem is that the lambda is taking more than the maximum 3 seconds that Slack is willing to wait for a response). --- requirements.in | 2 - turbot/interaction.py | 250 ++++++++++++++++++++++++++++----- turbot/slack.py | 37 +++++ turbot_lambda/turbot_lambda.py | 9 +- 4 files changed, 255 insertions(+), 43 deletions(-) diff --git a/requirements.in b/requirements.in index 40386ce..818cb25 100644 --- a/requirements.in +++ b/requirements.in @@ -1,5 +1,3 @@ -flask -flask_restful google-api-python-client google-auth-httplib2 google-auth-oauthlib diff --git a/turbot/interaction.py b/turbot/interaction.py index 4606449..947a01f 100644 --- a/turbot/interaction.py +++ b/turbot/interaction.py @@ -1,16 +1,59 @@ -from turbot.blocks import input_block +from slack.errors import SlackApiError +from turbot.blocks import input_block, section_block, text_block +import turbot.rot import turbot.sheets +import turbot.slack import json import re import requests -import turbot.rot + +TURBOT_USER_ID = 'U01B9QM4P9R' + +actions = {} +commands = {} +submission_handlers = {} + +# Hunt and Puzzle IDs are restricted to letters, numbers, and underscores +valid_id_re = r'^[_a-zA-Z0-9]+$' + +def bot_reply(message): + """Construct a return value suitable for a bot reply + + This is suitable as a way to give an error back to the user who + initiated a slash command, for example.""" + + return { + 'statusCode': 200, + 'body': message + } + +def submission_error(field, error): + """Construct an error suitable for returning for an invalid submission. + + Returning this value will prevent a submission and alert the user that + the given field is invalid because of the given error.""" + + print("Rejecting invalid modal submission: {}".format(error)) + + return { + 'statusCode': 200, + 'headers': { + "Content-Type": "application/json" + }, + 'body': json.dumps({ + "response_action": "errors", + "errors": { + field: error + } + }) + } def new_hunt(turb, payload): """Handler for the action of user pressing the new_hunt button""" view = { "type": "modal", - "private_metadata": "new_hunt", + "private_metadata": json.dumps({}), "title": { "type": "plain_text", "text": "New Hunt" }, "submit": { "type": "plain_text", "text": "Create" }, "blocks": [ @@ -33,7 +76,9 @@ def new_hunt(turb, payload): 'body': 'OK' } -def new_hunt_submission(turb, payload): +actions['button'] = {"new_hunt": new_hunt} + +def new_hunt_submission(turb, payload, metadata): """Handler for the user submitting the new hunt modal This is the modal view presented to the user by the new_hunt @@ -45,31 +90,23 @@ def new_hunt_submission(turb, payload): url = state['url']['url']['value'] # Validate that the hunt_id contains no invalid characters - if not re.match(r'[_a-zA-Z0-9]+$', hunt_id): - print("Hunt ID field is invalid. Attmpting to return a clean error.") - return { - 'statusCode': 200, - 'headers': { - "Content-Type": "application/json" - }, - 'body': json.dumps({ - "response_action": "errors", - "errors": { - "hunt_id": "Hunt ID can only contain letters, " - + "numbers, and underscores" - } - }) - } + if not re.match(valid_id_re, hunt_id): + return submission_error("hunt_id", + "Hunt ID can only contain letters, " + + "numbers, and underscores") # Create a channel for the hunt - response = turb.slack_client.conversations_create(name=hunt_id) + try: + response = turb.slack_client.conversations_create(name=hunt_id) + except SlackApiError as e: + return submission_error("hunt_id", + "Error creating Slack channel: {}" + .format(e.response['error'])) if not response['ok']: - print("Error creating channel for hunt {}: {}" - .format(name, str(response))) - return { - 'statusCode': 400 - } + return submission_error("name", + "Error occurred creating Slack channel " + + "(see CloudWatch log") user_id = payload['user']['id'] channel_id = response['channel']['id'] @@ -98,6 +135,27 @@ def new_hunt_submission(turb, payload): text="Sheet created for this hunt: {}" .format(sheet['url'])) + # Create a database table for this hunt's puzzles + table = turb.db.create_table( + TableName=hunt_id, + AttributeDefinitions=[ + {'AttributeName': 'channel_id', 'AttributeType': 'S'} + ], + KeySchema=[ + {'AttributeName': 'channel_id', 'KeyType': 'HASH'} + ], + ProvisionedThroughput={ + 'ReadCapacityUnits': 5, + 'WriteCapacityUnits': 4 + } + ) + + # Message the hunt channel that the database is ready + turb.slack_client.chat_postMessage( + channel=channel_id, + text="Welcome to your new hunt! " + + "Use `/puzzle` to create puzzles for the hunt.") + return { 'statusCode': 200, } @@ -107,10 +165,11 @@ def view_submission(turb, payload): Specifically, those that have a payload type of 'view_submission'""" - view_id = payload['view']['private_metadata'] + view_id = payload['view']['id'] + metadata = payload['view']['private_metadata'] if view_id in submission_handlers: - return submission_handlers[view_id](turb, payload) + return submission_handlers[view_id](turb, payload, metadata) print("Error: Unknown view ID: {}".format(view_id)) return { @@ -152,12 +211,135 @@ def rot(turb, body, args): 'body': "" } -actions = { - "button": { - "new_hunt": new_hunt +commands["/rot"] = rot + +def puzzle(turb, body, args): + """Implementation of the /puzzle command + + The args string is currently ignored (this command will bring up + a modal dialog for user input instead).""" + + channel_id = body['channel_id'][0] + trigger_id = body['trigger_id'][0] + + hunts_table = turb.db.Table("hunts") + response = hunts_table.get_item(Key={'channel_id': channel_id}) + + if 'Item' in response: + hunt_name = response['Item']['name'] + hunt_id = response['Item']['hunt_id'] + else: + return bot_reply("Sorry, this channel doesn't appear to " + + "be a hunt channel") + + view = { + "type": "modal", + "private_metadata": json.dumps({ + "hunt_id": hunt_id, + "hunt_channel_id": channel_id + }), + "title": {"type": "plain_text", "text": "New Puzzle"}, + "submit": { "type": "plain_text", "text": "Create" }, + "blocks": [ + section_block(text_block("*For {}*".format(hunt_name))), + input_block("Puzzle name", "name", "Name of the puzzle"), + input_block("Puzzle ID", "puzzle_id", + "Used as part of channel name " + + "(no spaces nor punctuation)"), + input_block("Puzzle URL", "url", "External URL of puzzle", + optional=True) + ] + } + + result = turb.slack_client.views_open(trigger_id=trigger_id, + view=view) + + if (result['ok']): + submission_handlers[result['view']['id']] = puzzle_submission + + return { + 'statusCode': 200 } -} -commands = { - "/rot": rot -} +commands["/puzzle"] = puzzle + +def puzzle_submission(turb, payload, metadata): + """Handler for the user submitting the new puzzle modal + + This is the modal view presented to the user by the puzzle function + above.""" + + print("In puzzle_submission\npayload is: {}\nmetadata is {}" + .format(payload, metadata)) + + meta = json.loads(metadata) + hunt_id = meta['hunt_id'] + hunt_channel_id = meta['hunt_channel_id'] + + state = payload['view']['state']['values'] + name = state['name']['name']['value'] + puzzle_id = state['puzzle_id']['puzzle_id']['value'] + url = state['url']['url']['value'] + + # Validate that the puzzle_id contains no invalid characters + if not re.match(valid_id_re, puzzle_id): + return submission_error("puzzle_id", + "Puzzle ID can only contain letters, " + + "numbers, and underscores") + + # Create a channel for the puzzle + hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id) + + try: + response = turb.slack_client.conversations_create( + name=hunt_dash_channel) + except SlackApiError as e: + return submission_error("puzzle_id", + "Error creating Slack channel: {}" + .format(e.response['error'])) + + puzzle_channel_id = response['channel']['id'] + + # Create a sheet for the puzzle + sheet = turbot.sheets.sheets_create_for_puzzle(turb, hunt_dash_channel) + + # Insert the newly-created puzzle into the database + table = turb.db.Table(hunt_id) + + table.put_item( + Item={ + "channel_id": puzzle_channel_id, + "solution": [], + "status": 'unsolved', + "name": name, + "puzzle_id": puzzle_id, + "url": url, + "sheet_url": sheet['url'] + } + ) + + # Find all members of the hunt channel + members = turbot.slack.slack_channel_members(turb.slack_client, + hunt_channel_id) + + # Filter out Turbot's own ID to avoid inviting itself + members = [m for m in members if m != TURBOT_USER_ID] + + turb.slack_client.chat_postMessage(channel=puzzle_channel_id, + text="Inviting members: {}".format(str(members))) + + # Invite those members to the puzzle channel (in chunks of 500) + cursor = 0 + while cursor < len(members): + turb.slack_client.conversations_invite( + channel=puzzle_channel_id, + users=members[cursor:cursor + 500]) + cursor += 500 + + # Message the channel with the URL of the puzzle's sheet + turb.slack_client.chat_postMessage(channel=puzzle_channel_id, + text="Sheet created for this puzzle: {}" + .format(sheet['url'])) + return { + 'statusCode': 200 + } diff --git a/turbot/slack.py b/turbot/slack.py index d2d683d..02f87e8 100644 --- a/turbot/slack.py +++ b/turbot/slack.py @@ -1,6 +1,16 @@ import hashlib import hmac import os +import boto3 + +if 'SLACK_SIGNING_SECRET' in os.environ: + slack_signing_secret = os.environ['SLACK_SIGNING_SECRET'] +else: + ssm = boto3.client('ssm') + response = ssm.get_parameter(Name='SLACK_SIGNING_SECRET', + WithDecryption=True) + slack_signing_secret = response['Parameter']['Value'] + os.environ['SLACK_SIGNING_SECRET'] = slack_signing_secret slack_signing_secret = bytes(os.environ['SLACK_SIGNING_SECRET'], 'utf-8') @@ -21,3 +31,30 @@ def slack_is_valid_request(slack_signature, timestamp, body): else: print("Bad signature: {} != {}".format(signature, slack_signature)) return False + +def slack_channel_members(slack_client, channel_id): + members = [] + + cursor = None + while True: + if cursor: + response = slack_client.conversations_members(channel=channel_id, + cursor=cursor) + else: + response = slack_client.conversations_members(channel=channel_id) + + if response['ok']: + members += response['members'] + else: + print("Error querying members of channel {}: {}" + .format(channel_id, response['error'])) + return members + + cursor = None + if 'next_cursor' in response['response_metadata']: + cursor = response['response_metadata']['next_cursor'] + + if not cursor or cursor == '': + break + + return members diff --git a/turbot_lambda/turbot_lambda.py b/turbot_lambda/turbot_lambda.py index 8365739..7469222 100644 --- a/turbot_lambda/turbot_lambda.py +++ b/turbot_lambda/turbot_lambda.py @@ -4,7 +4,6 @@ import base64 import boto3 import requests import json -import os import pickle from types import SimpleNamespace from google.auth.transport.requests import Request @@ -15,10 +14,6 @@ import turbot.events ssm = boto3.client('ssm') -response = ssm.get_parameter(Name='SLACK_SIGNING_SECRET', WithDecryption=True) -slack_signing_secret = response['Parameter']['Value'] -os.environ['SLACK_SIGNING_SECRET'] = slack_signing_secret - # Note: Late import here to have the environment variable above available from turbot.slack import slack_is_valid_request # noqa @@ -167,7 +162,7 @@ def turbot_interactive(turb, payload): if type == 'view_submission': return turbot.interaction.view_submission(turb, payload) if type == 'shortcut': - return turbot_shortcut(turb, payload); + return turbot_shortcut(turb, payload) return error("Unrecognized interactive type: {}".format(type)) def turbot_block_action(turb, payload): @@ -216,6 +211,6 @@ def turbot_slash_command(turb, body): args = '' if command in turbot.interaction.commands: - return turbot.interation.commands[command](turb, body, args) + return turbot.interaction.commands[command](turb, body, args) return error("Command {} not implemented".format(command)) -- 2.43.0