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(
99 status_emoji, solution_str,
101 channel_url(channel_id), name,
102 ', '.join(links), rounds_str,
106 # Combining hunt ID and puzzle ID together here is safe because
107 # hunt_id is restricted to not contain a hyphen, (see
108 # valid_id_re in interaction.py)
109 hunt_and_sort_key = "{}-{}".format(puzzle['hunt_id'], puzzle['SK'])
113 section_block(text_block(puzzle_text)),
114 button_block("✏", "edit_puzzle", hunt_and_sort_key)
118 def puzzle_matches_one(puzzle, pattern):
119 """Returns True if this puzzle matches the given string (regexp)
121 A match will be considered on any of puzzle title, round title,
122 puzzle URL, puzzle state, or solution string. The string can
123 include regular expression syntax. Matching is case insensitive.
126 p = re.compile('.*'+pattern+'.*', re.IGNORECASE)
128 if p.match(puzzle['name']):
131 if 'rounds' in puzzle:
132 for round in puzzle['rounds']:
137 if p.match(puzzle['url']):
140 if 'state' in puzzle:
141 if p.match(puzzle['state']):
144 if 'solution' in puzzle:
145 for solution in puzzle['solution']:
146 if p.match(solution):
151 def puzzle_matches_all(puzzle, patterns):
152 """Returns True if this puzzle matches all of the given list of patterns
154 A match will be considered on any of puzzle title, round title,
155 puzzle URL, puzzle state, or solution string. All patterns must
156 match the puzzle somewhere, (that is, there is an implicit logical
157 AND between patterns). Patterns can include regular expression
158 syntax. Matching is case insensitive.
161 for pattern in patterns:
162 if not puzzle_matches_one(puzzle, pattern):
167 def puzzle_id_from_name(name):
168 return re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
170 def puzzle_sort_key(puzzle):
171 """Return an appropriate sort key for a puzzle in the database
173 The sort key must start with "puzzle-" to distinguish puzzle items
174 in the database from all non-puzzle items. After that, though, the
175 only requirements are that each puzzle have a unique key and they
176 give us the ordering we want. And for ordering, we want meta puzzles
177 before non-meta puzzles and then alphabetical order by name within
178 each of those groups.
180 So puting a "-meta-" prefix in front of the puzzle ID does the trick.
183 return "puzzle-{}{}".format(
184 "-meta-" if puzzle['type'] == "meta" else "",
188 def puzzle_channel_topic(puzzle):
189 """Compute the channel topic for a puzzle"""
193 if puzzle['status'] == 'solved':
194 topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
196 topic += puzzle['name']
200 url = puzzle.get('url', None)
202 links.append("<{}|Puzzle>".format(url))
204 sheet_url = puzzle.get('sheet_url', None)
206 links.append("<{}|Sheet>".format(sheet_url))
209 topic += "({})".format(', '.join(links))
211 state = puzzle.get('state', None)
213 topic += " {}".format(state)
217 def puzzle_channel_name(puzzle):
218 """Compute the channel name for a puzzle"""
220 # Note: We don't use puzzle['puzzle_id'] here because we're keeping
221 # that as a persistent identifier in the database. Instead we
222 # create a new ID-like identifier from the current name.
223 channel_name = "{}-{}".format(
225 puzzle_id_from_name(puzzle['name'])
228 if puzzle['status'] == 'solved':
229 channel_name += "-solved"
233 def puzzle_sheet_name(puzzle):
234 """Compute the sheet name for a puzzle"""
236 sheet_name = puzzle['name']
237 if puzzle['status'] == 'solved':
238 sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
242 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
244 channel_id = puzzle['channel_id']
246 # Compute the channel topic and set it if it has changed
247 channel_topic = puzzle_channel_topic(puzzle)
249 old_channel_topic = None
251 old_channel_topic = puzzle_channel_topic(old_puzzle)
253 if channel_topic != old_channel_topic:
254 # Slack only allows 250 characters for a topic
255 if len(channel_topic) > 250:
256 channel_topic = channel_topic[:247] + "..."
257 turb.slack_client.conversations_setTopic(channel=channel_id,
260 # Compute the sheet name and set it if it has changed
261 sheet_name = puzzle_sheet_name(puzzle)
263 old_sheet_name = None
265 old_sheet_name = puzzle_sheet_name(old_puzzle)
267 if sheet_name != old_sheet_name:
268 turbot.sheets.renameSheet(turb, puzzle['sheet_url'], sheet_name)
270 # Compute the Slack channel name and set it if it has changed
271 channel_name = puzzle_channel_name(puzzle)
273 old_channel_name = None
275 old_channel_name = puzzle_channel_name(old_puzzle)
277 if channel_name != old_channel_name:
278 turb.slack_client.conversations_rename(