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)
68 if status == 'solved':
69 status_emoji = ":ballot_box_with_check:"
71 status_emoji = ":white_square:"
74 solution_str = "*`" + '`, `'.join(solution) + "`*"
77 if puzzle.get('type', 'plain') == 'meta':
82 links.append("<{}|Puzzle>".format(url))
84 links.append("<{}|Sheet>".format(sheet_url))
88 state_str = "\n{}".format(state)
91 if include_rounds and 'rounds' in puzzle:
92 rounds = puzzle['rounds']
93 rounds_str = " in round{}: {}".format(
94 "s" if len(rounds) > 1 else "",
98 puzzle_text = "{} {}<{}|{}> {} ({}){}{}".format(
101 channel_url(channel_id), name,
103 ', '.join(links), rounds_str,
107 # Combining hunt ID and puzzle ID together here is safe because
108 # hunt_id is restricted to not contain a hyphen, (see
109 # valid_id_re in interaction.py)
110 hunt_and_sort_key = "{}-{}".format(puzzle['hunt_id'], puzzle['SK'])
114 section_block(text_block(puzzle_text)),
115 button_block("✏", "edit_puzzle", hunt_and_sort_key)
119 def puzzle_matches_one(puzzle, pattern):
120 """Returns True if this puzzle matches the given string (regexp)
122 A match will be considered on any of puzzle title, round title,
123 puzzle URL, puzzle state, or solution string. The string can
124 include regular expression syntax. Matching is case insensitive.
127 p = re.compile('.*'+pattern+'.*', re.IGNORECASE)
129 if p.match(puzzle['name']):
132 if 'rounds' in puzzle:
133 for round in puzzle['rounds']:
138 if p.match(puzzle['url']):
141 if 'state' in puzzle:
142 if p.match(puzzle['state']):
145 if 'solution' in puzzle:
146 for solution in puzzle['solution']:
147 if p.match(solution):
152 def puzzle_matches_all(puzzle, patterns):
153 """Returns True if this puzzle matches all of the given list of patterns
155 A match will be considered on any of puzzle title, round title,
156 puzzle URL, puzzle state, or solution string. All patterns must
157 match the puzzle somewhere, (that is, there is an implicit logical
158 AND between patterns). Patterns can include regular expression
159 syntax. Matching is case insensitive.
162 for pattern in patterns:
163 if not puzzle_matches_one(puzzle, pattern):
168 def puzzle_id_from_name(name):
169 return re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
171 def puzzle_sort_key(puzzle):
172 """Return an appropriate sort key for a puzzle in the database
174 The sort key must start with "puzzle-" to distinguish puzzle items
175 in the database from all non-puzzle items. After that, though, the
176 only requirements are that each puzzle have a unique key and they
177 give us the ordering we want. And for ordering, we want meta puzzles
178 before non-meta puzzles and then alphabetical order by name within
179 each of those groups.
181 So puting a "-meta-" prefix in front of the puzzle ID does the trick.
184 return "puzzle-{}{}".format(
185 "-meta-" if puzzle['type'] == "meta" else "",
189 def puzzle_channel_topic(puzzle):
190 """Compute the channel topic for a puzzle"""
194 if puzzle['status'] == 'solved':
195 topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
197 topic += puzzle['name']
201 url = puzzle.get('url', None)
203 links.append("<{}|Puzzle>".format(url))
205 sheet_url = puzzle.get('sheet_url', None)
207 links.append("<{}|Sheet>".format(sheet_url))
210 topic += "({})".format(', '.join(links))
212 state = puzzle.get('state', None)
214 topic += " {}".format(state)
218 def puzzle_channel_name(puzzle):
219 """Compute the channel name for a puzzle"""
221 # Note: We don't use puzzle['puzzle_id'] here because we're keeping
222 # that as a persistent identifier in the database. Instead we
223 # create a new ID-like identifier from the current name.
224 channel_name = "{}-{}".format(
226 puzzle_id_from_name(puzzle['name'])
229 if puzzle['status'] == 'solved':
230 channel_name += "-solved"
234 def puzzle_sheet_name(puzzle):
235 """Compute the sheet name for a puzzle"""
237 sheet_name = puzzle['name']
238 if puzzle['status'] == 'solved':
239 sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
243 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
245 channel_id = puzzle['channel_id']
247 # Compute the channel topic and set it if it has changed
248 channel_topic = puzzle_channel_topic(puzzle)
250 old_channel_topic = None
252 old_channel_topic = puzzle_channel_topic(old_puzzle)
254 if channel_topic != old_channel_topic:
255 # Slack only allows 250 characters for a topic
256 if len(channel_topic) > 250:
257 channel_topic = channel_topic[:247] + "..."
258 turb.slack_client.conversations_setTopic(channel=channel_id,
261 # Compute the sheet name and set it if it has changed
262 sheet_name = puzzle_sheet_name(puzzle)
264 old_sheet_name = None
266 old_sheet_name = puzzle_sheet_name(old_puzzle)
268 if sheet_name != old_sheet_name:
269 turbot.sheets.renameSheet(turb, puzzle['sheet_url'], sheet_name)
271 # Compute the Slack channel name and set it if it has changed
272 channel_name = puzzle_channel_name(puzzle)
274 old_channel_name = None
276 old_channel_name = puzzle_channel_name(old_puzzle)
278 if channel_name != old_channel_name:
279 turb.slack_client.conversations_rename(