]> git.cworth.org Git - turbot/blobdiff - turbot/interaction.py
Implement a new `/puzzle` slash command to create a puzzle
[turbot] / turbot / interaction.py
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
+    }