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_description(puzzle):
276 """Compute the channel description for a puzzle"""
278 url = puzzle.get('url', None)
279 sheet_url = puzzle.get('sheet_url', None)
280 tags = puzzle.get('tags', [])
281 state = puzzle.get('state', None)
284 "Puzzle: \"{}\".\n".format(puzzle['name'])
289 links += " <{}|Original puzzle> ".format(url)
292 links += " <{}|Sheet>".format(sheet_url)
295 description += "Links:{}\n".format(links)
298 description += "Tags: {}\n".format(
299 " ".join(["`{}`".format(t) for t in tags]))
302 description += "State: {}\n".format(state)
306 def puzzle_channel_name(puzzle):
307 """Compute the channel name for a puzzle"""
310 if 'rounds' in puzzle:
311 round = '-' + puzzle_id_from_name(puzzle['rounds'][0])
314 if puzzle.get('type', 'plain') == 'meta':
317 # Note: We don't use puzzle['puzzle_id'] here because we're keeping
318 # that as a persistent identifier in the database. Instead we
319 # create a new ID-like identifier from the current name.
320 channel_name = "{}{}{}-{}".format(
324 puzzle_id_from_name(puzzle['name'])
327 if puzzle['status'] == 'solved':
328 channel_name += "-solved"
332 def puzzle_sheet_name(puzzle):
333 """Compute the sheet name for a puzzle"""
335 sheet_name = puzzle['name']
336 if puzzle['status'] == 'solved':
337 sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
341 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
343 channel_id = puzzle['channel_id']
345 # Compute the channel topic and set it if it has changed
346 channel_topic = puzzle_channel_topic(puzzle)
348 old_channel_topic = None
350 old_channel_topic = puzzle_channel_topic(old_puzzle)
352 if channel_topic != old_channel_topic:
353 # Slack only allows 250 characters for a topic
354 if len(channel_topic) > 250:
355 channel_topic = channel_topic[:247] + "..."
356 turb.slack_client.conversations_setTopic(channel=channel_id,
359 # Compute the channel description and set it if it has changed
360 channel_description = puzzle_channel_description(puzzle)
362 old_channel_description = None
364 old_channel_description = puzzle_channel_description(old_puzzle)
366 if channel_description != old_channel_description:
367 # Slack also only allows 250 characters for a description
368 if len(channel_description) > 250:
369 channel_description = channel_description[:247] + "..."
370 turb.slack_client.conversations_setPurpose(channel=channel_id,
371 purpose=channel_description)
373 # Compute the sheet name and set it if it has changed
374 sheet_name = puzzle_sheet_name(puzzle)
376 old_sheet_name = None
378 old_sheet_name = puzzle_sheet_name(old_puzzle)
380 if sheet_name != old_sheet_name:
381 turbot.sheets.rename_spreadsheet(turb, puzzle['sheet_url'], sheet_name)
383 # Compute the Slack channel name and set it if it has changed
384 channel_name = puzzle_channel_name(puzzle)
386 old_channel_name = None
388 old_channel_name = puzzle_channel_name(old_puzzle)
390 if channel_name != old_channel_name:
391 turb.slack_client.conversations_rename(
396 # A copy deep enough to work for puzzle_update_channel_and_sheet above
397 def puzzle_copy(old_puzzle):
398 new_puzzle = old_puzzle.copy()
400 if 'tags' in old_puzzle:
401 new_puzzle['tags'] = old_puzzle['tags'].copy()
403 if 'solution' in old_puzzle:
404 new_puzzle['solution'] = old_puzzle['solution'].copy()