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']))
252 topic += puzzle['name']
256 url = puzzle.get('url', None)
258 links.append("<{}|Puzzle>".format(url))
260 sheet_url = puzzle.get('sheet_url', None)
262 links.append("<{}|Sheet>".format(sheet_url))
265 topic += "({})".format(', '.join(links))
267 tags = puzzle.get('tags', [])
269 topic += " {}".format(" ".join(["`{}`".format(t) for t in tags]))
271 state = puzzle.get('state', None)
273 topic += " {}".format(state)
277 def puzzle_channel_name(puzzle):
278 """Compute the channel name for a puzzle"""
281 if 'rounds' in puzzle:
282 round = '-' + puzzle_id_from_name(puzzle['rounds'][0])
285 if puzzle.get('type', 'plain') == 'meta':
288 # Note: We don't use puzzle['puzzle_id'] here because we're keeping
289 # that as a persistent identifier in the database. Instead we
290 # create a new ID-like identifier from the current name.
291 channel_name = "{}{}{}-{}".format(
295 puzzle_id_from_name(puzzle['name'])
298 if puzzle['status'] == 'solved':
299 channel_name += "-solved"
303 def puzzle_sheet_name(puzzle):
304 """Compute the sheet name for a puzzle"""
306 sheet_name = puzzle['name']
307 if puzzle['status'] == 'solved':
308 sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
312 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
314 channel_id = puzzle['channel_id']
316 # Compute the channel topic and set it if it has changed
317 channel_topic = puzzle_channel_topic(puzzle)
319 old_channel_topic = None
321 old_channel_topic = puzzle_channel_topic(old_puzzle)
323 if channel_topic != old_channel_topic:
324 # Slack only allows 250 characters for a topic
325 if len(channel_topic) > 250:
326 channel_topic = channel_topic[:247] + "..."
327 turb.slack_client.conversations_setTopic(channel=channel_id,
330 # Compute the sheet name and set it if it has changed
331 sheet_name = puzzle_sheet_name(puzzle)
333 old_sheet_name = None
335 old_sheet_name = puzzle_sheet_name(old_puzzle)
337 if sheet_name != old_sheet_name:
338 turbot.sheets.rename_spreadsheet(turb, puzzle['sheet_url'], sheet_name)
340 # Compute the Slack channel name and set it if it has changed
341 channel_name = puzzle_channel_name(puzzle)
343 old_channel_name = None
345 old_channel_name = puzzle_channel_name(old_puzzle)
347 if channel_name != old_channel_name:
348 turb.slack_client.conversations_rename(
353 # A copy deep enough to work for puzzle_update_channel_and_sheet above
354 def puzzle_copy(old_puzzle):
355 new_puzzle = old_puzzle.copy()
357 if 'tags' in old_puzzle:
358 new_puzzle['tags'] = old_puzzle['tags'].copy()