From 4db58774a54d2c8505ee61aff3019723b1b552eb Mon Sep 17 00:00:00 2001 From: Carl Worth Date: Tue, 5 Jan 2021 08:31:34 -0800 Subject: [PATCH] Add search terms to the /hunt command Now, `/hunt ` can be used to list puzzles matching . As before, it will display only unsolved puzzles by default, but `/hunt all ` or `/hunt solved ` can be used to display other puzzles as well. --- turbot/hunt.py | 49 ++++++++++++++++++++++++++++++++++++------- turbot/interaction.py | 39 +++++++++++++++++++++++++++------- turbot/puzzle.py | 44 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 15 deletions(-) diff --git a/turbot/hunt.py b/turbot/hunt.py index b538ea5..a832f1d 100644 --- a/turbot/hunt.py +++ b/turbot/hunt.py @@ -1,6 +1,6 @@ from turbot.blocks import section_block, text_block, divider_block from turbot.round import round_blocks -from turbot.puzzle import puzzle_block +from turbot.puzzle import puzzle_block, puzzle_matches_all from turbot.channel import channel_url from boto3.dynamodb.conditions import Key @@ -23,16 +23,32 @@ def find_hunt_for_hunt_id(turb, hunt_id): else: return None -def hunt_blocks(turb, hunt, puzzle_status='unsolved'): +def quote_if_has_space(term): + if ' ' in term: + return '"{}"'.format(term) + else: + return term + +def hunt_blocks(turb, hunt, puzzle_status='unsolved', search_terms=[]): """Generate Slack blocks for a hunt The hunt argument should be a dictionary as returned from the database. - Option 'puzzle_status' indicates which puzzles to include. If - either 'solved' or 'unsolved' only puzzles with that status from - the hunt will be included in the result. If any other value, all - puzzles in the hunt will be included. + Two optional arguments can be used to filter which puzzles to + include in the result: + + puzzle_status: If either 'solved' or 'unsolved' only puzzles + with that status will be included in the + result. If any other value, all puzzles in the + hunt will be considered. + + search_terms: A list of search terms. Only puzzles that match + all of these terms will be included in the + result. A match will be considered on any of + puzzle title, round title, puzzle URL, or solution + string. Terms can include regular expression + syntax. The return value can be used in a Slack command expecting blocks to provide all the details of a hunt, (puzzles, their state, @@ -55,6 +71,12 @@ def hunt_blocks(turb, hunt, puzzle_status='unsolved'): # Filter the set of puzzles according the the requested puzzle_status if puzzle_status in ('solved', 'unsolved'): puzzles = [p for p in puzzles if p['status'] == puzzle_status] + else: + puzzle_status = 'all' + + if search_terms: + puzzles = [puzzle for puzzle in puzzles + if puzzle_matches_all(puzzle, search_terms)] # Compute the set of rounds across all puzzles rounds = set() @@ -64,12 +86,25 @@ def hunt_blocks(turb, hunt, puzzle_status='unsolved'): for round in puzzle['rounds']: rounds.add(round) - hunt_text = "*<{}|{}>*".format(channel_url(channel_id), name) + hunt_text = "*{} puzzles in hunt <{}|{}>".format( + puzzle_status.capitalize(), + channel_url(channel_id), + name + ) + if search_terms: + quoted_terms = [quote_if_has_space(term) for term in search_terms] + hunt_text += " matching {}".format(" AND ".join(quoted_terms)) + hunt_text += "*" blocks = [ section_block(text_block(hunt_text)), ] + if not len(puzzles): + blocks += [ + section_block(text_block("No puzzles found.")) + ] + # Construct blocks for each round for round in rounds: blocks += round_blocks(round, puzzles) diff --git a/turbot/interaction.py b/turbot/interaction.py index 9045778..164e431 100644 --- a/turbot/interaction.py +++ b/turbot/interaction.py @@ -13,6 +13,7 @@ import requests from botocore.exceptions import ClientError from boto3.dynamodb.conditions import Key from turbot.slack import slack_send_message +import shlex actions = {} commands = {} @@ -617,21 +618,43 @@ commands["/solved"] = solved def hunt(turb, body, args): """Implementation of the /hunt command - The (optional) args string should be one of 'all', 'solved', or - 'unsolved' to indicate which set of puzzles should be - displayed. If omitted, this command will default to showing only - unsolved puzzles. + The (optional) args string can be used to filter which puzzles to + display. The first word can be one of 'all', 'unsolved', or + 'solved' and can be used to display only puzzles with the given + status. Any remaining text in the args string will be interpreted + as search terms. These will be split into separate terms on space + characters, (though quotation marks can be used to include a space + character in a term). All terms must match on a puzzle in order + for that puzzle to be included. But a puzzle will be considered to + match if any of the puzzle title, round title, puzzle URL, or + puzzle solution match. Matching will be performed without regard + to case sensitivity and the search terms can include regular + expression syntax. """ channel_id = body['channel_id'][0] response_url = body['response_url'][0] - status = args - if len(status): + if args: + # The first word can be a puzzle status and all remaining word + # (if any) are search terms. _But_, if the first word is not a + # valid puzzle status ('all', 'unsolved', 'solved'), then all + # words are search terms and we default status to 'unsolved'. + split_args = args.split(' ', 1) + status = split_args[0] + if (len(split_args) > 1): + terms = split_args[1] if status not in ('unsolved', 'solved', 'all'): - return bot_reply("Usage: /hunt [all|solved|unsolved]") + terms = args + status = 'unsolved' else: status = 'unsolved' + terms = None + + # Separate search terms on spaces (but allow for quotation marks + # to capture spaces in a search term) + if terms: + terms = shlex.split(terms) hunt = hunt_for_channel(turb, channel_id) @@ -639,7 +662,7 @@ def hunt(turb, body, args): return bot_reply("Sorry, this channel doesn't appear to " + "be a hunt or puzzle channel") - blocks = hunt_blocks(turb, hunt, puzzle_status=status) + blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms) requests.post(response_url, json = { 'blocks': blocks }, diff --git a/turbot/puzzle.py b/turbot/puzzle.py index 810c97e..8f044b1 100644 --- a/turbot/puzzle.py +++ b/turbot/puzzle.py @@ -1,6 +1,7 @@ from turbot.blocks import section_block, text_block from turbot.channel import channel_url from boto3.dynamodb.conditions import Key +import re def find_puzzle_for_url(turb, hunt_id, url): """Given a hunt_id and URL, return the puzzle with that URL @@ -67,3 +68,46 @@ def puzzle_block(puzzle): ) return section_block(text_block(puzzle_text)) + +def puzzle_matches_one(puzzle, pattern): + """Returns True if this puzzle matches the given string (regexp) + + A match will be considered on any of puzzle title, round title, + puzzle URL, or solution string. The string can include regular + expression syntax. Matching is case insensitive. + """ + + p = re.compile('.*'+pattern+'.*', re.IGNORECASE) + + if p.match(puzzle['name']): + return True + + if 'rounds' in puzzle: + for round in puzzle['rounds']: + if p.match(round): + return True + + if p.match(puzzle['url']): + return True + + for solution in puzzle['solution']: + if p.match(solution): + return True + + return False + +def puzzle_matches_all(puzzle, patterns): + """Returns True if this puzzle matches all of the given list of patterns + + A match will be considered on any of puzzle title, round title, + puzzle URL, or solution string. All patterns must match the puzzle + somewhere, (that is, there is an implicit logical AND between + patterns). Patterns can include regular expression + syntax. Matching is case insensitive. + """ + + for pattern in patterns: + if not puzzle_matches_one(puzzle, pattern): + return False + + return True -- 2.43.0