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, or solution string. The string can
133 include regular expression syntax. Matching is case insensitive.
136 p = re.compile('.*'+pattern+'.*', re.IGNORECASE)
138 if p.match(puzzle['name']):
141 if 'rounds' in puzzle:
142 for round in puzzle['rounds']:
147 if p.match(puzzle['url']):
150 if 'state' in puzzle:
151 if p.match(puzzle['state']):
154 if 'solution' in puzzle:
155 for solution in puzzle['solution']:
156 if p.match(solution):
161 def puzzle_matches_all(puzzle, patterns):
162 """Returns True if this puzzle matches all of the given list of patterns
164 A match will be considered on any of puzzle title, round title,
165 puzzle URL, puzzle state, or solution string. All patterns must
166 match the puzzle somewhere, (that is, there is an implicit logical
167 AND between patterns). Patterns can include regular expression
168 syntax. Matching is case insensitive.
171 for pattern in patterns:
172 if not puzzle_matches_one(puzzle, pattern):
177 def puzzle_id_from_name(name):
178 return re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
180 def puzzle_sort_key(puzzle):
181 """Return an appropriate sort key for a puzzle in the database
183 The sort key must start with "puzzle-" to distinguish puzzle items
184 in the database from all non-puzzle items. After that, though, the
185 only requirements are that each puzzle have a unique key and they
186 give us the ordering we want. And for ordering, we want meta puzzles
187 before non-meta puzzles and then alphabetical order by name within
188 each of those groups.
190 So puting a "-meta-" prefix in front of the puzzle ID does the trick.
193 return "puzzle-{}{}".format(
194 "-meta-" if puzzle['type'] == "meta" else "",
198 def puzzle_channel_topic(puzzle):
199 """Compute the channel topic for a puzzle"""
203 if puzzle['status'] == 'solved':
204 topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
206 topic += puzzle['name']
210 url = puzzle.get('url', None)
212 links.append("<{}|Puzzle>".format(url))
214 sheet_url = puzzle.get('sheet_url', None)
216 links.append("<{}|Sheet>".format(sheet_url))
219 topic += "({})".format(', '.join(links))
221 tags = puzzle.get('tags', [])
223 topic += " {}".format(" ".join(["`{}`".format(t) for t in tags]))
225 state = puzzle.get('state', None)
227 topic += " {}".format(state)
231 def puzzle_channel_name(puzzle):
232 """Compute the channel name for a puzzle"""
234 # Note: We don't use puzzle['puzzle_id'] here because we're keeping
235 # that as a persistent identifier in the database. Instead we
236 # create a new ID-like identifier from the current name.
237 channel_name = "{}-{}".format(
239 puzzle_id_from_name(puzzle['name'])
242 if puzzle['status'] == 'solved':
243 channel_name += "-solved"
247 def puzzle_sheet_name(puzzle):
248 """Compute the sheet name for a puzzle"""
250 sheet_name = puzzle['name']
251 if puzzle['status'] == 'solved':
252 sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
256 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
258 channel_id = puzzle['channel_id']
260 # Compute the channel topic and set it if it has changed
261 channel_topic = puzzle_channel_topic(puzzle)
263 old_channel_topic = None
265 old_channel_topic = puzzle_channel_topic(old_puzzle)
267 if channel_topic != old_channel_topic:
268 # Slack only allows 250 characters for a topic
269 if len(channel_topic) > 250:
270 channel_topic = channel_topic[:247] + "..."
271 turb.slack_client.conversations_setTopic(channel=channel_id,
274 # Compute the sheet name and set it if it has changed
275 sheet_name = puzzle_sheet_name(puzzle)
277 old_sheet_name = None
279 old_sheet_name = puzzle_sheet_name(old_puzzle)
281 if sheet_name != old_sheet_name:
282 turbot.sheets.renameSheet(turb, puzzle['sheet_url'], sheet_name)
284 # Compute the Slack channel name and set it if it has changed
285 channel_name = puzzle_channel_name(puzzle)
287 old_channel_name = None
289 old_channel_name = puzzle_channel_name(old_puzzle)
291 if channel_name != old_channel_name:
292 turb.slack_client.conversations_rename(