]> git.cworth.org Git - turbot/commitdiff
Implement a new `/puzzle` slash command to create a puzzle
authorCarl Worth <cworth@cworth.org>
Wed, 21 Oct 2020 23:12:42 +0000 (16:12 -0700)
committerCarl Worth <cworth@cworth.org>
Thu, 22 Oct 2020 06:50:12 +0000 (23:50 -0700)
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
turbot/interaction.py
turbot/slack.py
turbot_lambda/turbot_lambda.py

index 40386ce4ad3480c6c8661756d93b7f69e81dde64..818cb25fac3a551ec4ac0dd533786fccfc2f1f88 100644 (file)
@@ -1,5 +1,3 @@
-flask
-flask_restful
 google-api-python-client
 google-auth-httplib2
 google-auth-oauthlib
index 4606449edca9921850fd6473b7ced5f9a55f77d0..947a01f67a48fee942f672551bd03f196dcdad2e 100644 (file)
@@ -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
+    }
index d2d683d067dbd0ae797af095a5439fecabd6fd02..02f87e806a94db5588140f523f81beb723f8efe0 100644 (file)
@@ -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
index 8365739fa24e5e7881d282a79ddd4b0d47663ee1..7469222cb4bd84e6fecf3489551ac528e42fa905 100644 (file)
@@ -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))