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 "Discussion to solve the puzzle \"{}\".\n".format(puzzle['name'])
288 description += "See the <{}|Original puzzle>\n".format(url)
292 "Actual solving work takes place in the "
293 + "<{}|shared spreadsheet>\n".format(sheet_url)
297 description += "This puzzle has the following tags: {}\n".format(
298 " ".join(["`{}`".format(t) for t in tags]))
301 description += "This puzzle has a state of: {}\n".format(state)
304 "You can see a summary of this information at any point "
305 + "by issuing the `/puzzle` command and you can edit any of "
306 + "this information by issuing the `/edit` command"
311 def puzzle_channel_name(puzzle):
312 """Compute the channel name for a puzzle"""
315 if 'rounds' in puzzle:
316 round = '-' + puzzle_id_from_name(puzzle['rounds'][0])
319 if puzzle.get('type', 'plain') == 'meta':
322 # Note: We don't use puzzle['puzzle_id'] here because we're keeping
323 # that as a persistent identifier in the database. Instead we
324 # create a new ID-like identifier from the current name.
325 channel_name = "{}{}{}-{}".format(
329 puzzle_id_from_name(puzzle['name'])
332 if puzzle['status'] == 'solved':
333 channel_name += "-solved"
337 def puzzle_sheet_name(puzzle):
338 """Compute the sheet name for a puzzle"""
340 sheet_name = puzzle['name']
341 if puzzle['status'] == 'solved':
342 sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
346 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
348 channel_id = puzzle['channel_id']
350 # Compute the channel topic and set it if it has changed
351 channel_topic = puzzle_channel_topic(puzzle)
353 old_channel_topic = None
355 old_channel_topic = puzzle_channel_topic(old_puzzle)
357 if channel_topic != old_channel_topic:
358 # Slack only allows 250 characters for a topic
359 if len(channel_topic) > 250:
360 channel_topic = channel_topic[:247] + "..."
361 turb.slack_client.conversations_setTopic(channel=channel_id,
364 # Compute the channel description and set it if it has changed
365 channel_description = puzzle_channel_description(puzzle)
367 old_channel_description = None
369 old_channel_description = puzzle_channel_description(old_puzzle)
371 if channel_description != old_channel_description:
372 turb.slack_client.conversations_setPurpose(channel=channel_id,
373 purpose=channel_description)
375 # Compute the sheet name and set it if it has changed
376 sheet_name = puzzle_sheet_name(puzzle)
378 old_sheet_name = None
380 old_sheet_name = puzzle_sheet_name(old_puzzle)
382 if sheet_name != old_sheet_name:
383 turbot.sheets.rename_spreadsheet(turb, puzzle['sheet_url'], sheet_name)
385 # Compute the Slack channel name and set it if it has changed
386 channel_name = puzzle_channel_name(puzzle)
388 old_channel_name = None
390 old_channel_name = puzzle_channel_name(old_puzzle)
392 if channel_name != old_channel_name:
393 turb.slack_client.conversations_rename(
398 # A copy deep enough to work for puzzle_update_channel_and_sheet above
399 def puzzle_copy(old_puzzle):
400 new_puzzle = old_puzzle.copy()
402 if 'tags' in old_puzzle:
403 new_puzzle['tags'] = old_puzzle['tags'].copy()
405 if 'solution' in old_puzzle:
406 new_puzzle['solution'] = old_puzzle['solution'].copy()