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 puzzle_id, return that puzzle
12 Returns None if no puzzle with the given hunt_id and puzzle_id
13 exists in the database, otherwise a dictionary with all fields
14 from the puzzle's row in the database.
17 response = turb.table.get_item(
23 if 'Item' in response:
24 return response['Item']
28 def find_puzzle_for_url(turb, hunt_id, url):
29 """Given a hunt_id and URL, return the puzzle with that URL
31 Returns None if no puzzle with the given URL exists in the database,
32 otherwise a dictionary with all fields from the puzzle's row in
36 response = turb.table.query(
37 IndexName='url_index',
38 KeyConditionExpression=(
39 Key('hunt_id').eq(hunt_id) &
44 if response['Count'] == 0:
47 return response['Items'][0]
49 def puzzle_blocks(puzzle, include_rounds=False):
50 """Generate Slack blocks for a puzzle
52 The puzzle argument should be a dictionary as returned from the
53 database. The return value can be used in a Slack command
54 expecting blocks to provide all the details of a puzzle, (its
55 state, solution, links to channel and sheet, etc.).
59 status = puzzle['status']
60 solution = puzzle['solution']
61 channel_id = puzzle['channel_id']
62 url = puzzle.get('url', None)
63 sheet_url = puzzle.get('sheet_url', None)
64 state = puzzle.get('state', None)
65 tags = puzzle.get('tags', [])
69 if status == 'solved':
70 status_emoji = ":ballot_box_with_check:"
72 status_emoji = ":white_square:"
75 solution_str = "*`" + '`, `'.join(solution) + "`*"
78 if puzzle.get('type', 'plain') == 'meta':
83 links.append("<{}|Puzzle>".format(url))
85 links.append("<{}|Sheet>".format(sheet_url))
89 state_str = " State: {}".format(state)
93 tags_str = " Tags: "+" ".join(["`{}`".format(tag) for tag in tags])
96 if state_str or tags_str:
97 extra_str = "\n{}{}".format(tags_str, state_str)
100 if include_rounds and 'rounds' in puzzle:
101 rounds = puzzle['rounds']
102 rounds_str = " in round{}: {}".format(
103 "s" if len(rounds) > 1 else "",
107 puzzle_text = "{} {}<{}|{}> {} ({}){}{}".format(
110 channel_url(channel_id), name,
112 ', '.join(links), rounds_str,
116 # Combining hunt ID and puzzle ID together here is safe because
117 # hunt_id is restricted to not contain a hyphen, (see
118 # valid_id_re in interaction.py)
119 hunt_and_sort_key = "{}-{}".format(puzzle['hunt_id'], puzzle['SK'])
123 section_block(text_block(puzzle_text)),
124 button_block("✏", "edit_puzzle", hunt_and_sort_key)
128 def puzzle_matches_one(puzzle, pattern):
129 """Returns True if this puzzle matches the given string (regexp)
131 A match will be considered on any of puzzle title, round title,
132 puzzle URL, puzzle state, puzzle type, tags, or solution
133 string. The string can include regular expression syntax. Matching
137 p = re.compile('.*'+pattern+'.*', re.IGNORECASE)
139 if p.match(puzzle['name']):
142 if 'rounds' in puzzle:
143 for round in puzzle['rounds']:
148 if p.match(puzzle['url']):
151 if 'state' in puzzle:
152 if p.match(puzzle['state']):
156 if p.match(puzzle['type']):
159 if 'solution' in puzzle:
160 for solution in puzzle['solution']:
161 if p.match(solution):
165 for tag in puzzle['tags']:
171 def puzzle_matches_all(puzzle, patterns):
172 """Returns True if this puzzle matches all of the given list of patterns
174 A match will be considered on any of puzzle title, round title,
175 puzzle URL, puzzle state, puzzle types, tags, or solution
176 string. All patterns must match the puzzle somewhere, (that is,
177 there is an implicit logical AND between patterns). Patterns can
178 include regular expression syntax. Matching is case insensitive.
182 for pattern in patterns:
183 if not puzzle_matches_one(puzzle, pattern):
188 def puzzle_id_from_name(name):
189 return re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
191 def puzzle_sort_key(puzzle):
192 """Return an appropriate sort key for a puzzle in the database
194 The sort key must start with "puzzle-" to distinguish puzzle items
195 in the database from all non-puzzle items. After that, though, the
196 only requirements are that each puzzle have a unique key and they
197 give us the ordering we want. And for ordering, we want meta puzzles
198 before non-meta puzzles and then alphabetical order by name within
199 each of those groups.
201 So puting a "-meta-" prefix in front of the puzzle ID does the trick.
204 return "puzzle-{}{}".format(
205 "-meta-" if puzzle['type'] == "meta" else "",
209 def puzzle_channel_topic(puzzle):
210 """Compute the channel topic for a puzzle"""
214 if puzzle['status'] == 'solved':
215 topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
217 topic += puzzle['name']
221 url = puzzle.get('url', None)
223 links.append("<{}|Puzzle>".format(url))
225 sheet_url = puzzle.get('sheet_url', None)
227 links.append("<{}|Sheet>".format(sheet_url))
230 topic += "({})".format(', '.join(links))
232 tags = puzzle.get('tags', [])
234 topic += " {}".format(" ".join(["`{}`".format(t) for t in tags]))
236 state = puzzle.get('state', None)
238 topic += " {}".format(state)
242 def puzzle_channel_name(puzzle):
243 """Compute the channel name for a puzzle"""
246 if 'rounds' in puzzle:
247 round = '-' + puzzle_id_from_name(puzzle['rounds'][0])
250 if puzzle.get('type', 'plain') == 'meta':
253 # Note: We don't use puzzle['puzzle_id'] here because we're keeping
254 # that as a persistent identifier in the database. Instead we
255 # create a new ID-like identifier from the current name.
256 channel_name = "{}{}{}-{}".format(
260 puzzle_id_from_name(puzzle['name'])
263 if puzzle['status'] == 'solved':
264 channel_name += "-solved"
268 def puzzle_sheet_name(puzzle):
269 """Compute the sheet name for a puzzle"""
271 sheet_name = puzzle['name']
272 if puzzle['status'] == 'solved':
273 sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
277 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
279 channel_id = puzzle['channel_id']
281 # Compute the channel topic and set it if it has changed
282 channel_topic = puzzle_channel_topic(puzzle)
284 old_channel_topic = None
286 old_channel_topic = puzzle_channel_topic(old_puzzle)
288 if channel_topic != old_channel_topic:
289 # Slack only allows 250 characters for a topic
290 if len(channel_topic) > 250:
291 channel_topic = channel_topic[:247] + "..."
292 turb.slack_client.conversations_setTopic(channel=channel_id,
295 # Compute the sheet name and set it if it has changed
296 sheet_name = puzzle_sheet_name(puzzle)
298 old_sheet_name = None
300 old_sheet_name = puzzle_sheet_name(old_puzzle)
302 if sheet_name != old_sheet_name:
303 turbot.sheets.rename_spreadsheet(turb, puzzle['sheet_url'], sheet_name)
305 # Compute the Slack channel name and set it if it has changed
306 channel_name = puzzle_channel_name(puzzle)
308 old_channel_name = None
310 old_channel_name = puzzle_channel_name(old_puzzle)
312 if channel_name != old_channel_name:
313 turb.slack_client.conversations_rename(
318 # A copy deep enough to work for puzzle_update_channel_and_sheet above
319 def puzzle_copy(old_puzzle):
320 new_puzzle = old_puzzle.copy()
322 if 'tags' in old_puzzle:
323 new_puzzle['tags'] = old_puzzle['tags'].copy()