X-Git-Url: https://git.cworth.org/git?a=blobdiff_plain;f=turbot%2Finteraction.py;h=25307c22a26aec26084da1b623ebaee2acec069f;hb=HEAD;hp=53f45a701a9fb9bae68d41316af01fe29f518a14;hpb=5e7ee7c20f71996cedd04586d7139250c6704c0d;p=turbot diff --git a/turbot/interaction.py b/turbot/interaction.py index 53f45a7..5889c74 100644 --- a/turbot/interaction.py +++ b/turbot/interaction.py @@ -5,12 +5,15 @@ from turbot.blocks import ( from turbot.hunt import ( find_hunt_for_hunt_id, hunt_blocks, - hunt_puzzles_for_hunt_id + hunt_puzzles_for_hunt_id, + hunt_update_topic ) from turbot.puzzle import ( find_puzzle_for_url, find_puzzle_for_sort_key, + find_puzzle_for_puzzle_id, puzzle_update_channel_and_sheet, + puzzle_channel_name, puzzle_id_from_name, puzzle_blocks, puzzle_sort_key, @@ -18,6 +21,7 @@ from turbot.puzzle import ( ) from turbot.round import round_quoted_puzzles_titles_answers from turbot.help import turbot_help +from turbot.have_you_tried import have_you_tried import turbot.rot import turbot.sheets import turbot.slack @@ -213,6 +217,10 @@ def edit_puzzle(turb, puzzle, trigger_id): "New round(s) this puzzle belongs to " + "(comma separated)", optional=True), + input_block("Tag(s)", "tags", + "Tags for this puzzle (comma separated)", + initial_value=", ".join(puzzle.get("tags", [])), + optional=True), input_block("State", "state", "State of this puzzle (partial progress, next steps)", initial_value=puzzle.get("state", None), @@ -264,11 +272,13 @@ def edit_puzzle_submission(turb, payload, metadata): puzzle['type'] = 'meta' else: puzzle['type'] = 'plain' - rounds = [option['value'] for option in - state['rounds']['rounds']['selected_options']] - if rounds: - puzzle['rounds'] = rounds + if 'rounds' in state: + rounds = [option['value'] for option in + state['rounds']['rounds']['selected_options']] + if rounds: + puzzle['rounds'] = rounds new_rounds = state['new_rounds']['new_rounds']['value'] + tags = state['tags']['tags']['value'] puzzle_state = state['state']['state']['value'] if puzzle_state: puzzle['state'] = puzzle_state @@ -279,9 +289,10 @@ def edit_puzzle_submission(turb, payload, metadata): puzzle['solution'] = [] solution = state['solution']['solution']['value'] if solution: - puzzle['solution'] = [ + # Construct a list from a set to avoid any duplicates + puzzle['solution'] = list({ sol.strip() for sol in solution.split(',') - ] + }) # Verify that there's a solution if the puzzle is mark solved if puzzle['status'] == 'solved' and not puzzle['solution']: @@ -310,13 +321,31 @@ def edit_puzzle_submission(turb, payload, metadata): } ) + # Process any tags + puzzle['tags'] = [] + if tags: + for tag in tags.split(','): + # Drop any leading/trailing spaces from the tag + tag = tag.strip().upper() + # Ignore any empty string + if not len(tag): + continue + # Reject a tag that is not alphabetic or underscore A-Z_ + if not re.match(r'^[A-Z0-9_]*$', tag): + return submission_error( + "tags", + "Error: Tags can only contain letters, numbers, " + + "and the underscore character." + ) + puzzle['tags'].append(tag) + # Get old puzzle from the database (to determine what's changed) old_puzzle = find_puzzle_for_sort_key(turb, puzzle['hunt_id'], puzzle['SK']) # If we are changing puzzle type (meta -> plain or plain -> meta) - # the the sort key has to change, so compute the new one and delete + # then the sort key has to change, so compute the new one and delete # the old item from the database. # # XXX: We should really be using a transaction here to combine the @@ -344,9 +373,20 @@ def edit_puzzle_submission(turb, payload, metadata): turb.slack_client, puzzle['channel_id'], edit_message, blocks=blocks) + # Advertize any tag additions to the hunt + hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id']) + + new_tags = set(puzzle['tags']) - set(old_puzzle['tags']) + if new_tags: + message = "Puzzle <{}|{}> has been tagged: {}".format( + puzzle['channel_url'], + puzzle['name'], + ", ".join(['`{}`'.format(t) for t in new_tags]) + ) + slack_send_message(turb.slack_client, hunt['channel_id'], message) + # Also inform the hunt if the puzzle's solved status changed if puzzle['status'] != old_puzzle['status']: - hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id']) if puzzle['status'] == 'solved': message = "Puzzle <{}|{}> has been solved!".format( puzzle['channel_url'], @@ -420,6 +460,10 @@ def edit_hunt(turb, hunt, trigger_id): input_block("Hunt URL", "url", "External URL of hunt", initial_value=hunt.get("url", None), optional=True), + input_block("State", "state", + "State of the hunt (goals, upcoming meetings, etc.)", + initial_value=hunt.get("state", None), + optional=True), checkbox_block("Is this hunt active?", "Active", "active", checked=(hunt.get('active', False))) ] @@ -458,6 +502,9 @@ def edit_hunt_submission(turb, payload, metadata): if url: hunt['url'] = url + hunt_state = state['state']['state']['value'] + if hunt_state: + hunt['state'] = hunt_state if state['active']['active']['selected_options']: hunt['active'] = True else: @@ -483,6 +530,9 @@ def edit_hunt_submission(turb, payload, metadata): turb.slack_client, hunt['channel_id'], edit_message, blocks=blocks) + # Update channel topic and description + hunt_update_topic(turb, hunt) + return lambda_ok def new_hunt_command(turb, body): @@ -531,7 +581,7 @@ def new_hunt(turb, trigger_id): return lambda_ok -actions['button']['new_hunt'] = new_hunt +actions['button']['new_hunt'] = new_hunt_button def new_hunt_submission(turb, payload, metadata): """Handler for the user submitting the new hunt modal @@ -571,7 +621,8 @@ def new_hunt_submission(turb, payload, metadata): {'AttributeName': 'SK', 'AttributeType': 'S'}, {'AttributeName': 'channel_id', 'AttributeType': 'S'}, {'AttributeName': 'is_hunt', 'AttributeType': 'S'}, - {'AttributeName': 'url', 'AttributeType': 'S'} + {'AttributeName': 'url', 'AttributeType': 'S'}, + {'AttributeName': 'puzzle_id', 'AttributeType': 'S'} ], ProvisionedThroughput={ 'ReadCapacityUnits': 5, @@ -615,6 +666,16 @@ def new_hunt_submission(turb, payload, metadata): 'Projection': { 'ProjectionType': 'ALL' } + }, + { + 'IndexName': 'puzzle_id_index', + 'KeySchema': [ + {'AttributeName': 'hunt_id', 'KeyType': 'HASH'}, + {'AttributeName': 'puzzle_id', 'KeyType': 'RANGE'}, + ], + 'Projection': { + 'ProjectionType': 'ALL' + } } ] ) @@ -647,6 +708,9 @@ def new_hunt_submission(turb, payload, metadata): item['url'] = url turb.table.put_item(Item=item) + # Update channel topic and description + hunt_update_topic(turb, item) + # Invite the initiating user to the channel turb.slack_client.conversations_invite(channel=channel_id, users=user_id) @@ -914,13 +978,20 @@ def new_puzzle(turb, body): return bot_reply("Sorry, this channel doesn't appear to " + "be a hunt or puzzle channel") + # We used puzzle (if available) to select the initial round(s) + puzzle = puzzle_for_channel(turb, channel_id) + initial_rounds = None + if puzzle: + initial_rounds=puzzle.get("rounds", None) + round_options = hunt_rounds(turb, hunt['hunt_id']) if len(round_options): round_options_block = [ multi_select_block("Round(s)", "rounds", "Existing round(s) this puzzle belongs to", - round_options) + round_options, + initial_options=initial_rounds) ] else: round_options_block = [] @@ -942,7 +1013,10 @@ def new_puzzle(turb, body): input_block("New round(s)", "new_rounds", "New round(s) this puzzle belongs to " + "(comma separated)", - optional=True) + optional=True), + input_block("Tag(s)", "tags", + "Tags for this puzzle (comma separated)", + optional=True), ] } @@ -966,21 +1040,37 @@ def new_puzzle_submission(turb, payload, metadata): hunt_id = meta['hunt_id'] state = payload['view']['state']['values'] - name = state['name']['name']['value'] + + # And start loading data into a puzzle dict + puzzle = {} + puzzle['hunt_id'] = hunt_id + puzzle['name'] = state['name']['name']['value'] url = state['url']['url']['value'] + if url: + puzzle['url'] = url if state['meta']['meta']['selected_options']: - puzzle_type = 'meta' + puzzle['type'] = 'meta' else: - puzzle_type = 'plain' + puzzle['type'] = 'plain' if 'rounds' in state: rounds = [option['value'] for option in state['rounds']['rounds']['selected_options']] else: rounds = [] new_rounds = state['new_rounds']['new_rounds']['value'] + tags = state['tags']['tags']['value'] + + # Create a Slack-channel-safe puzzle_id + puzzle['puzzle_id'] = puzzle_id_from_name(puzzle['name']) # Before doing anything, reject this puzzle if a puzzle already - # exists with the same URL. + # exists with the same puzzle_id or url + existing = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle['puzzle_id']) + if existing: + return submission_error( + "name", + "Error: This name collides with an existing puzzle.") + if url: existing = find_puzzle_for_url(turb, hunt_id, url) if existing: @@ -988,23 +1078,6 @@ def new_puzzle_submission(turb, payload, metadata): "url", "Error: A puzzle with this URL already exists.") - # Create a Slack-channel-safe puzzle_id - puzzle_id = puzzle_id_from_name(name) - - # 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( - "name", - "Error creating Slack channel {}: {}" - .format(hunt_dash_channel, e.response['error'])) - - channel_id = response['channel']['id'] - # Add any new rounds to the database if new_rounds: for round in new_rounds.split(','): @@ -1021,21 +1094,44 @@ def new_puzzle_submission(turb, payload, metadata): } ) - # Construct a puzzle dict - puzzle = { - "hunt_id": hunt_id, - "puzzle_id": puzzle_id, - "channel_id": channel_id, - "solution": [], - "status": 'unsolved', - "name": name, - "type": puzzle_type - } - if url: - puzzle['url'] = url + # Process any tags + puzzle['tags'] = [] + if tags: + for tag in tags.split(','): + # Drop any leading/trailing spaces from the tag + tag = tag.strip().upper() + # Ignore any empty string + if not len(tag): + continue + # Reject a tag that is not alphabetic or underscore A-Z_ + if not re.match(r'^[A-Z0-9_]*$', tag): + return submission_error( + "tags", + "Error: Tags can only contain letters, numbers, " + + "and the underscore character." + ) + puzzle['tags'].append(tag) + if rounds: puzzle['rounds'] = rounds + puzzle['solution'] = [] + puzzle['status'] = 'unsolved' + + # Create a channel for the puzzle + channel_name = puzzle_channel_name(puzzle) + + try: + response = turb.slack_client.conversations_create( + name=channel_name) + except SlackApiError as e: + return submission_error( + "name", + "Error creating Slack channel {}: {}" + .format(channel_name, e.response['error'])) + + puzzle['channel_id'] = response['channel']['id'] + # Finally, compute the appropriate sort key puzzle["SK"] = puzzle_sort_key(puzzle) @@ -1133,6 +1229,17 @@ def tag(turb, body, args): puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle) + # Advertize any tag additions to the hunt + new_tags = set(puzzle['tags']) - set(old_puzzle['tags']) + if new_tags: + hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id']) + message = "Puzzle <{}|{}> has been tagged: {}".format( + puzzle['channel_url'], + puzzle['name'], + ", ".join(['`{}`'.format(t) for t in new_tags]) + ) + slack_send_message(turb.slack_client, hunt['channel_id'], message) + return lambda_ok commands["/tag"] = tag @@ -1159,7 +1266,10 @@ def solved(turb, body, args): # Set the status and solution fields in the database puzzle['status'] = 'solved' - puzzle['solution'].append(args) + + # Don't append a duplicate solution + if args not in puzzle['solution']: + puzzle['solution'].append(args) if 'state' in puzzle: del puzzle['state'] turb.table.put_item(Item=puzzle) @@ -1167,7 +1277,7 @@ def solved(turb, body, args): # Report the solution to the puzzle's channel slack_send_message( turb.slack_client, channel_id, - "Puzzle mark solved by <@{}>: `{}`".format(user_id, args)) + "Puzzle marked solved by <@{}>: `{}`".format(user_id, args)) # Also report the solution to the hunt channel hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id']) @@ -1185,6 +1295,50 @@ def solved(turb, body, args): commands["/solved"] = solved +def delete(turb, body, args): + """Implementation of the /delete command + + The argument to this command is the ID of a hunt. + + The command will report an error if the specified hunt is active. + + If the hunt is inactive, this command will archive all channels + from the hunt. + """ + + if not args: + return bot_reply("Error, no hunt provided. Usage: `/delete HUNT_ID`") + + hunt_id = args + hunt = find_hunt_for_hunt_id(turb, hunt_id) + + if not hunt: + return bot_reply("Error, no hunt named \"{}\" exists.".format(hunt_id)) + + if hunt['active']: + return bot_reply( + "Error, refusing to delete active hunt \"{}\".".format(hunt_id) + ) + + if hunt['hunt_id'] != hunt_id: + return bot_reply( + "Error, expected hunt ID of \"{}\" but found \"{}\".".format( + hunt_id, hunt['hunt_id'] + ) + ) + + puzzles = hunt_puzzles_for_hunt_id(turb, hunt_id) + + for puzzle in puzzles: + channel_id = puzzle['channel_id'] + turb.slack_client.conversations_archive(channel=channel_id) + + turb.slack_client.conversations_archive(channel=hunt['channel_id']) + + return lambda_ok + +commands["/delete"] = delete + def hunt(turb, body, args): """Implementation of the /hunt command @@ -1245,10 +1399,13 @@ def hunt(turb, body, args): blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms) - requests.post(response_url, - json = { 'blocks': blocks }, - headers = {'Content-type': 'application/json'} - ) + for block in blocks: + if len(block) > 100: + block = block[:100] + requests.post(response_url, + json = { 'blocks': block }, + headers = {'Content-type': 'application/json'} + ) return lambda_ok @@ -1319,10 +1476,13 @@ def round(turb, body, args): limit_to_rounds=puzzle.get('rounds', []) ) - requests.post(response_url, - json = { 'blocks': blocks }, - headers = {'Content-type': 'application/json'} - ) + for block in blocks: + if len(block) > 100: + block = block[:100] + requests.post(response_url, + json = { 'blocks': block }, + headers = {'Content-type': 'application/json'} + ) return lambda_ok @@ -1336,19 +1496,37 @@ def help_command(turb, body, args): channel_id = body['channel_id'][0] response_url = body['response_url'][0] + user_id = body['user_id'][0] + + # Process "/help me" first. It calls out to have_you_tried rather + # than going through our help system. + # + # Also, it reports in the current channel, (where all other help + # output is reported privately to the invoking user). + if args == "me": + to_try = "In response to <@{}> asking `/help me`:\n\n{}\n".format( + user_id, have_you_tried()) + + # We'll try first to reply directly to the channel (for the benefit + # of anyone else in the same channel that might be stuck too. + # + # But if this doesn't work, (direct message or private channel), + # then we can instead reply with an ephemeral message by using + # the response_url. + try: + turb.slack_client.chat_postMessage( + channel=channel_id, text=to_try) + except SlackApiError: + requests.post(response_url, + json = {"text": to_try}, + headers = {"Content-type": "application/json"}) + return lambda_ok help_string = turbot_help(args) - # The "/help me" command is special in that it reports in the - # current channel, (where all other commands report privately to - # the invoking user). - if args == "me": - turb.slack_client.chat_postMessage( - channel=channel_id, text=help_string) - else: - requests.post(response_url, - json = {"text": help_string}, - headers = {"Content-type": "application/json"}) + requests.post(response_url, + json = {"text": help_string}, + headers = {"Content-type": "application/json"}) return lambda_ok