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
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,
# 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()
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)
from botocore.exceptions import ClientError
from boto3.dynamodb.conditions import Key
from turbot.slack import slack_send_message
+import shlex
actions = {}
commands = {}
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)
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 },
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
)
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