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_puzzle_id(turb, hunt_id, puzzle_id):
36 """Given a hunt_id and puzzle_id, return that puzzle
38 Returns None if no puzzle with the given hunt_id and puzzle_id
39 exists in the database, otherwise a dictionary with all fields
40 from the puzzle's row in the database.
42 Note: The sort_key is a modified version of the puzzle_id, (used
43 to make metapuzzles appear in the ordering before non-metapuzzles).
44 If you've been handed a sort_key, then looking up a puzzle by
45 sort_key is the right thing to do. But if you instead have just
46 a puzzle_id, see find_puzzle_for_puzzle_id rather than trying
47 to convert the puzzle_id into a sort_key to use this function.
50 response = turb.table.query(
51 IndexName='puzzle_id_index',
52 KeyConditionExpression=(
53 Key('hunt_id').eq(hunt_id) &
54 Key('puzzle_id').eq(puzzle_id)
58 if response['Count'] == 0:
61 return response['Items'][0]
63 def find_puzzle_for_url(turb, hunt_id, url):
64 """Given a hunt_id and URL, return the puzzle with that URL
66 Returns None if no puzzle with the given URL exists in the database,
67 otherwise a dictionary with all fields from the puzzle's row in
71 response = turb.table.query(
72 IndexName='url_index',
73 KeyConditionExpression=(
74 Key('hunt_id').eq(hunt_id) &
79 if response['Count'] == 0:
82 return response['Items'][0]
84 def puzzle_blocks(puzzle, include_rounds=False):
85 """Generate Slack blocks for a puzzle
87 The puzzle argument should be a dictionary as returned from the
88 database. The return value can be used in a Slack command
89 expecting blocks to provide all the details of a puzzle, (its
90 state, solution, links to channel and sheet, etc.).
94 status = puzzle['status']
95 solution = puzzle['solution']
96 channel_id = puzzle['channel_id']
97 url = puzzle.get('url', None)
98 sheet_url = puzzle.get('sheet_url', None)
99 state = puzzle.get('state', None)
100 tags = puzzle.get('tags', [])
104 if status == 'solved':
105 status_emoji = ":ballot_box_with_check:"
107 status_emoji = ":white_square:"
110 solution_str = "*`" + '`, `'.join(solution) + "`*"
113 if puzzle.get('type', 'plain') == 'meta':
118 links.append("<{}|Puzzle>".format(url))
120 links.append("<{}|Sheet>".format(sheet_url))
124 state_str = " State: {}".format(state)
128 tags_str = " Tags: "+" ".join(["`{}`".format(tag) for tag in tags])
131 if state_str or tags_str:
132 extra_str = "\n{}{}".format(tags_str, state_str)
135 if include_rounds and 'rounds' in puzzle:
136 rounds = puzzle['rounds']
137 rounds_str = " in round{}: {}".format(
138 "s" if len(rounds) > 1 else "",
142 puzzle_text = "{} {}<{}|{}> {} ({}){}{}".format(
145 channel_url(channel_id), name,
147 ', '.join(links), rounds_str,
151 # Combining hunt ID and puzzle ID together here is safe because
152 # hunt_id is restricted to not contain a hyphen, (see
153 # valid_id_re in interaction.py)
154 hunt_and_sort_key = "{}-{}".format(puzzle['hunt_id'], puzzle['SK'])
158 section_block(text_block(puzzle_text)),
159 button_block("✏", "edit_puzzle", hunt_and_sort_key)
163 def puzzle_matches_one(puzzle, pattern):
164 """Returns True if this puzzle matches the given string (regexp)
166 A match will be considered on any of puzzle title, round title,
167 puzzle URL, puzzle state, puzzle type, tags, or solution
168 string. The string can include regular expression syntax. Matching
172 p = re.compile('.*'+pattern+'.*', re.IGNORECASE)
174 if p.match(puzzle['name']):
177 if 'rounds' in puzzle:
178 for round in puzzle['rounds']:
183 if p.match(puzzle['url']):
186 if 'state' in puzzle:
187 if p.match(puzzle['state']):
191 if p.match(puzzle['type']):
194 if 'solution' in puzzle:
195 for solution in puzzle['solution']:
196 if p.match(solution):
200 for tag in puzzle['tags']:
206 def puzzle_matches_all(puzzle, patterns):
207 """Returns True if this puzzle matches all of the given list of patterns
209 A match will be considered on any of puzzle title, round title,
210 puzzle URL, puzzle state, puzzle types, tags, or solution
211 string. All patterns must match the puzzle somewhere, (that is,
212 there is an implicit logical AND between patterns). Patterns can
213 include regular expression syntax. Matching is case insensitive.
217 for pattern in patterns:
218 if not puzzle_matches_one(puzzle, pattern):
223 def puzzle_id_from_name(name):
224 return re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
226 def puzzle_sort_key(puzzle):
227 """Return an appropriate sort key for a puzzle in the database
229 The sort key must start with "puzzle-" to distinguish puzzle items
230 in the database from all non-puzzle items. After that, though, the
231 only requirements are that each puzzle have a unique key and they
232 give us the ordering we want. And for ordering, we want meta puzzles
233 before non-meta puzzles and then alphabetical order by name within
234 each of those groups.
236 So puting a "-meta-" prefix in front of the puzzle ID does the trick.
239 return "puzzle-{}{}".format(
240 "-meta-" if puzzle['type'] == "meta" else "",
244 def puzzle_channel_topic(puzzle):
245 """Compute the channel topic for a puzzle"""
249 if puzzle['status'] == 'solved':
250 topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
254 url = puzzle.get('url', None)
256 links.append("<{}|Puzzle>".format(url))
258 sheet_url = puzzle.get('sheet_url', None)
260 links.append("<{}|Sheet>".format(sheet_url))
263 topic += "({}) ".format(', '.join(links))
265 tags = puzzle.get('tags', [])
267 topic += " {}".format(" ".join(["`{}`".format(t) for t in tags]))
269 state = puzzle.get('state', None)
271 topic += " {}".format(state)
275 def puzzle_channel_name(puzzle):
276 """Compute the channel name for a puzzle"""
279 if 'rounds' in puzzle:
280 round = '-' + puzzle_id_from_name(puzzle['rounds'][0])
283 if puzzle.get('type', 'plain') == 'meta':
286 # Note: We don't use puzzle['puzzle_id'] here because we're keeping
287 # that as a persistent identifier in the database. Instead we
288 # create a new ID-like identifier from the current name.
289 channel_name = "{}{}{}-{}".format(
293 puzzle_id_from_name(puzzle['name'])
296 if puzzle['status'] == 'solved':
297 channel_name += "-solved"
301 def puzzle_sheet_name(puzzle):
302 """Compute the sheet name for a puzzle"""
304 sheet_name = puzzle['name']
305 if puzzle['status'] == 'solved':
306 sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
310 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
312 channel_id = puzzle['channel_id']
314 # Compute the channel topic and set it if it has changed
315 channel_topic = puzzle_channel_topic(puzzle)
317 old_channel_topic = None
319 old_channel_topic = puzzle_channel_topic(old_puzzle)
321 if channel_topic != old_channel_topic:
322 # Slack only allows 250 characters for a topic
323 if len(channel_topic) > 250:
324 channel_topic = channel_topic[:247] + "..."
325 turb.slack_client.conversations_setTopic(channel=channel_id,
328 # Compute the sheet name and set it if it has changed
329 sheet_name = puzzle_sheet_name(puzzle)
331 old_sheet_name = None
333 old_sheet_name = puzzle_sheet_name(old_puzzle)
335 if sheet_name != old_sheet_name:
336 turbot.sheets.rename_spreadsheet(turb, puzzle['sheet_url'], sheet_name)
338 # Compute the Slack channel name and set it if it has changed
339 channel_name = puzzle_channel_name(puzzle)
341 old_channel_name = None
343 old_channel_name = puzzle_channel_name(old_puzzle)
345 if channel_name != old_channel_name:
346 turb.slack_client.conversations_rename(
351 # A copy deep enough to work for puzzle_update_channel_and_sheet above
352 def puzzle_copy(old_puzzle):
353 new_puzzle = old_puzzle.copy()
355 if 'tags' in old_puzzle:
356 new_puzzle['tags'] = old_puzzle['tags'].copy()
358 if 'solution' in old_puzzle:
359 new_puzzle['solution'] = old_puzzle['solution'].copy()