]> git.cworth.org Git - turbot/commitdiff
Add search terms to the /hunt command
authorCarl Worth <cworth@cworth.org>
Tue, 5 Jan 2021 16:31:34 +0000 (08:31 -0800)
committerCarl Worth <cworth@cworth.org>
Tue, 5 Jan 2021 18:19:02 +0000 (10:19 -0800)
Now, `/hunt <search-term>` can be used to list puzzles matching
<search-term>. As before, it will display only unsolved puzzles by
default, but `/hunt all <search-term>` or `/hunt solved <search-term>`
can be used to display other puzzles as well.

turbot/hunt.py
turbot/interaction.py
turbot/puzzle.py

index b538ea578c1405798af2a3332c55e4833360af48..a832f1db2693aed5efdc5016d24d2c6f7ccaac93 100644 (file)
@@ -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)
index 9045778c232878e9b9898b5090bafbfbfd496aa5..164e4311fbca7c76c262438cb758fe437cdbeb46 100644 (file)
@@ -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 },
index 810c97e5e671c977bab712b79acd4ddf973e5516..8f044b1ca7996fb6f24793639c84621d6413bebd 100644 (file)
@@ -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