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 round_id_from_name(name):
227 """Normalize and abbreviate round name for use as a prefix
228 in a channel name."""
230 return re.sub(r'[^a-zA-Z0-9_]', '', name).lower()[:7]
232 def puzzle_sort_key(puzzle):
233 """Return an appropriate sort key for a puzzle in the database
235 The sort key must start with "puzzle-" to distinguish puzzle items
236 in the database from all non-puzzle items. After that, though, the
237 only requirements are that each puzzle have a unique key and they
238 give us the ordering we want. And for ordering, we want meta puzzles
239 before non-meta puzzles and then alphabetical order by name within
240 each of those groups.
242 So puting a "-meta-" prefix in front of the puzzle ID does the trick.
245 return "puzzle-{}{}".format(
246 "-meta-" if puzzle['type'] == "meta" else "",
250 def puzzle_channel_topic(puzzle):
251 """Compute the channel topic for a puzzle"""
255 if puzzle['status'] == 'solved':
256 topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
260 url = puzzle.get('url', None)
262 links.append("<{}|Puzzle>".format(url))
264 sheet_url = puzzle.get('sheet_url', None)
266 links.append("<{}|Sheet>".format(sheet_url))
269 topic += "({}) ".format(', '.join(links))
271 tags = puzzle.get('tags', [])
273 topic += " {}".format(" ".join(["`{}`".format(t) for t in tags]))
275 state = puzzle.get('state', None)
277 topic += " {}".format(state)
281 def puzzle_channel_description(puzzle):
282 """Compute the channel description for a puzzle"""
284 url = puzzle.get('url', None)
285 sheet_url = puzzle.get('sheet_url', None)
286 tags = puzzle.get('tags', [])
287 state = puzzle.get('state', None)
290 "Puzzle: \"{}\".\n".format(puzzle['name'])
295 links += " <{}|Original puzzle> ".format(url)
298 links += " <{}|Sheet>".format(sheet_url)
301 description += "Links:{}\n".format(links)
304 description += "Tags: {}\n".format(
305 " ".join(["`{}`".format(t) for t in tags]))
308 description += "State: {}\n".format(state)
312 def puzzle_channel_name(puzzle):
313 """Compute the channel name for a puzzle"""
316 if 'rounds' in puzzle:
317 round = '-' + round_id_from_name(puzzle['rounds'][0])
320 if puzzle.get('type', 'plain') == 'meta':
323 # Note: We don't use puzzle['puzzle_id'] here because we're keeping
324 # that as a persistent identifier in the database. Instead we
325 # create a new ID-like identifier from the current name.
326 channel_name = "{}{}{}-{}".format(
330 puzzle_id_from_name(puzzle['name'])
333 if puzzle['status'] == 'solved':
334 channel_name += "-solved"
338 def puzzle_sheet_name(puzzle):
339 """Compute the sheet name for a puzzle"""
341 sheet_name = puzzle['name']
342 if puzzle['status'] == 'solved':
343 sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
347 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
349 channel_id = puzzle['channel_id']
351 # Compute the channel topic and set it if it has changed
352 channel_topic = puzzle_channel_topic(puzzle)
354 old_channel_topic = None
356 old_channel_topic = puzzle_channel_topic(old_puzzle)
358 if channel_topic != old_channel_topic:
359 # Slack only allows 250 characters for a topic
360 if len(channel_topic) > 250:
361 channel_topic = channel_topic[:247] + "..."
362 turb.slack_client.conversations_setTopic(channel=channel_id,
365 # Compute the channel description and set it if it has changed
366 channel_description = puzzle_channel_description(puzzle)
368 old_channel_description = None
370 old_channel_description = puzzle_channel_description(old_puzzle)
372 if channel_description != old_channel_description:
373 # Slack also only allows 250 characters for a description
374 if len(channel_description) > 250:
375 channel_description = channel_description[:247] + "..."
376 turb.slack_client.conversations_setPurpose(channel=channel_id,
377 purpose=channel_description)
379 # Compute the sheet name and set it if it has changed
380 sheet_name = puzzle_sheet_name(puzzle)
382 old_sheet_name = None
384 old_sheet_name = puzzle_sheet_name(old_puzzle)
386 if sheet_name != old_sheet_name:
387 turbot.sheets.rename_spreadsheet(turb, puzzle['sheet_url'], sheet_name)
389 # Compute the Slack channel name and set it if it has changed
390 channel_name = puzzle_channel_name(puzzle)
392 old_channel_name = None
394 old_channel_name = puzzle_channel_name(old_puzzle)
396 if channel_name != old_channel_name:
397 turb.slack_client.conversations_rename(
402 # A copy deep enough to work for puzzle_update_channel_and_sheet above
403 def puzzle_copy(old_puzzle):
404 new_puzzle = old_puzzle.copy()
406 if 'tags' in old_puzzle:
407 new_puzzle['tags'] = old_puzzle['tags'].copy()
409 if 'solution' in old_puzzle:
410 new_puzzle['solution'] = old_puzzle['solution'].copy()