1 from turbot.blocks import (
2 section_block, text_block, button_block, accessory_block
4 from turbot.channel import channel_url
5 from boto3.dynamodb.conditions import Key
9 def find_puzzle_for_sort_key(turb, hunt_id, sort_key):
10 """Given a hunt_id and sort_key, return that puzzle
12 Returns None if no puzzle with the given hunt_id and sort_key
13 exists in the database, otherwise a dictionary with all fields
14 from the puzzle's row in the database.
16 Note: The sort_key is a modified version of the puzzle_id, (used
17 to make metapuzzles appear in the ordering before non-metapuzzles).
18 If you've been handed a sort_key, then looking up a puzzle by
19 sort_key is the right thing to do. But if you instead have just
20 a puzzle_id, see find_puzzle_for_puzzle_id rather than trying
21 to convert the puzzle_id into a sort_key to use this function.
24 response = turb.table.get_item(
30 if 'Item' in response:
31 return response['Item']
35 def find_puzzle_for_url(turb, hunt_id, url):
36 """Given a hunt_id and URL, return the puzzle with that URL
38 Returns None if no puzzle with the given URL exists in the database,
39 otherwise a dictionary with all fields from the puzzle's row in
43 response = turb.table.query(
44 IndexName='url_index',
45 KeyConditionExpression=(
46 Key('hunt_id').eq(hunt_id) &
51 if response['Count'] == 0:
54 return response['Items'][0]
56 def puzzle_blocks(puzzle, include_rounds=False):
57 """Generate Slack blocks for a puzzle
59 The puzzle argument should be a dictionary as returned from the
60 database. The return value can be used in a Slack command
61 expecting blocks to provide all the details of a puzzle, (its
62 state, solution, links to channel and sheet, etc.).
66 status = puzzle['status']
67 solution = puzzle['solution']
68 channel_id = puzzle['channel_id']
69 url = puzzle.get('url', None)
70 sheet_url = puzzle.get('sheet_url', None)
71 state = puzzle.get('state', None)
72 tags = puzzle.get('tags', [])
76 if status == 'solved':
77 status_emoji = ":ballot_box_with_check:"
79 status_emoji = ":white_square:"
82 solution_str = "*`" + '`, `'.join(solution) + "`*"
85 if puzzle.get('type', 'plain') == 'meta':
90 links.append("<{}|Puzzle>".format(url))
92 links.append("<{}|Sheet>".format(sheet_url))
96 state_str = " State: {}".format(state)
100 tags_str = " Tags: "+" ".join(["`{}`".format(tag) for tag in tags])
103 if state_str or tags_str:
104 extra_str = "\n{}{}".format(tags_str, state_str)
107 if include_rounds and 'rounds' in puzzle:
108 rounds = puzzle['rounds']
109 rounds_str = " in round{}: {}".format(
110 "s" if len(rounds) > 1 else "",
114 puzzle_text = "{} {}<{}|{}> {} ({}){}{}".format(
117 channel_url(channel_id), name,
119 ', '.join(links), rounds_str,
123 # Combining hunt ID and puzzle ID together here is safe because
124 # hunt_id is restricted to not contain a hyphen, (see
125 # valid_id_re in interaction.py)
126 hunt_and_sort_key = "{}-{}".format(puzzle['hunt_id'], puzzle['SK'])
130 section_block(text_block(puzzle_text)),
131 button_block("✏", "edit_puzzle", hunt_and_sort_key)
135 def puzzle_matches_one(puzzle, pattern):
136 """Returns True if this puzzle matches the given string (regexp)
138 A match will be considered on any of puzzle title, round title,
139 puzzle URL, puzzle state, puzzle type, tags, or solution
140 string. The string can include regular expression syntax. Matching
144 p = re.compile('.*'+pattern+'.*', re.IGNORECASE)
146 if p.match(puzzle['name']):
149 if 'rounds' in puzzle:
150 for round in puzzle['rounds']:
155 if p.match(puzzle['url']):
158 if 'state' in puzzle:
159 if p.match(puzzle['state']):
163 if p.match(puzzle['type']):
166 if 'solution' in puzzle:
167 for solution in puzzle['solution']:
168 if p.match(solution):
172 for tag in puzzle['tags']:
178 def puzzle_matches_all(puzzle, patterns):
179 """Returns True if this puzzle matches all of the given list of patterns
181 A match will be considered on any of puzzle title, round title,
182 puzzle URL, puzzle state, puzzle types, tags, or solution
183 string. All patterns must match the puzzle somewhere, (that is,
184 there is an implicit logical AND between patterns). Patterns can
185 include regular expression syntax. Matching is case insensitive.
189 for pattern in patterns:
190 if not puzzle_matches_one(puzzle, pattern):
195 def puzzle_id_from_name(name):
196 return re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
198 def puzzle_sort_key(puzzle):
199 """Return an appropriate sort key for a puzzle in the database
201 The sort key must start with "puzzle-" to distinguish puzzle items
202 in the database from all non-puzzle items. After that, though, the
203 only requirements are that each puzzle have a unique key and they
204 give us the ordering we want. And for ordering, we want meta puzzles
205 before non-meta puzzles and then alphabetical order by name within
206 each of those groups.
208 So puting a "-meta-" prefix in front of the puzzle ID does the trick.
211 return "puzzle-{}{}".format(
212 "-meta-" if puzzle['type'] == "meta" else "",
216 def puzzle_channel_topic(puzzle):
217 """Compute the channel topic for a puzzle"""
221 if puzzle['status'] == 'solved':
222 topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
224 topic += puzzle['name']
228 url = puzzle.get('url', None)
230 links.append("<{}|Puzzle>".format(url))
232 sheet_url = puzzle.get('sheet_url', None)
234 links.append("<{}|Sheet>".format(sheet_url))
237 topic += "({})".format(', '.join(links))
239 tags = puzzle.get('tags', [])
241 topic += " {}".format(" ".join(["`{}`".format(t) for t in tags]))
243 state = puzzle.get('state', None)
245 topic += " {}".format(state)
249 def puzzle_channel_name(puzzle):
250 """Compute the channel name for a puzzle"""
253 if 'rounds' in puzzle:
254 round = '-' + puzzle_id_from_name(puzzle['rounds'][0])
257 if puzzle.get('type', 'plain') == 'meta':
260 # Note: We don't use puzzle['puzzle_id'] here because we're keeping
261 # that as a persistent identifier in the database. Instead we
262 # create a new ID-like identifier from the current name.
263 channel_name = "{}{}{}-{}".format(
267 puzzle_id_from_name(puzzle['name'])
270 if puzzle['status'] == 'solved':
271 channel_name += "-solved"
275 def puzzle_sheet_name(puzzle):
276 """Compute the sheet name for a puzzle"""
278 sheet_name = puzzle['name']
279 if puzzle['status'] == 'solved':
280 sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
284 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
286 channel_id = puzzle['channel_id']
288 # Compute the channel topic and set it if it has changed
289 channel_topic = puzzle_channel_topic(puzzle)
291 old_channel_topic = None
293 old_channel_topic = puzzle_channel_topic(old_puzzle)
295 if channel_topic != old_channel_topic:
296 # Slack only allows 250 characters for a topic
297 if len(channel_topic) > 250:
298 channel_topic = channel_topic[:247] + "..."
299 turb.slack_client.conversations_setTopic(channel=channel_id,
302 # Compute the sheet name and set it if it has changed
303 sheet_name = puzzle_sheet_name(puzzle)
305 old_sheet_name = None
307 old_sheet_name = puzzle_sheet_name(old_puzzle)
309 if sheet_name != old_sheet_name:
310 turbot.sheets.rename_spreadsheet(turb, puzzle['sheet_url'], sheet_name)
312 # Compute the Slack channel name and set it if it has changed
313 channel_name = puzzle_channel_name(puzzle)
315 old_channel_name = None
317 old_channel_name = puzzle_channel_name(old_puzzle)
319 if channel_name != old_channel_name:
320 turb.slack_client.conversations_rename(
325 # A copy deep enough to work for puzzle_update_channel_and_sheet above
326 def puzzle_copy(old_puzzle):
327 new_puzzle = old_puzzle.copy()
329 if 'tags' in old_puzzle:
330 new_puzzle['tags'] = old_puzzle['tags'].copy()