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_puzzle_id(turb, hunt_id, puzzle_id):
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(
20 'SK': 'puzzle-{}'.format(puzzle_id)
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 # both IDs are restricted to not contain a hyphen, (see
108 # valid_id_re in interaction.py)
109 hunt_and_puzzle = "{}-{}".format(puzzle['hunt_id'], puzzle['puzzle_id'])
113 section_block(text_block(puzzle_text)),
114 button_block("✏", "edit_puzzle", hunt_and_puzzle)
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_channel_topic(puzzle):
171 """Compute the channel topic for a puzzle"""
175 if puzzle['status'] == 'solved':
176 topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
178 topic += puzzle['name']
182 url = puzzle.get('url', None)
184 links.append("<{}|Puzzle>".format(url))
186 sheet_url = puzzle.get('sheet_url', None)
188 links.append("<{}|Sheet>".format(sheet_url))
191 topic += "({})".format(', '.join(links))
193 state = puzzle.get('state', None)
195 topic += " {}".format(state)
199 def puzzle_channel_name(puzzle):
200 """Compute the channel name for a puzzle"""
202 # Note: We don't use puzzle['puzzle_id'] here because we're keeping
203 # that as a persistent identifier in the database. Instead we
204 # create a new ID-like identifier from the current name.
205 channel_name = "{}-{}".format(
207 puzzle_id_from_name(puzzle['name'])
210 if puzzle['status'] == 'solved':
211 channel_name += "-solved"
215 def puzzle_sheet_name(puzzle):
216 """Compute the sheet name for a puzzle"""
218 sheet_name = puzzle['name']
219 if puzzle['status'] == 'solved':
220 sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
224 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
226 channel_id = puzzle['channel_id']
228 # Compute the channel topic and set it if it has changed
229 channel_topic = puzzle_channel_topic(puzzle)
231 old_channel_topic = None
233 old_channel_topic = puzzle_channel_topic(old_puzzle)
235 if channel_topic != old_channel_topic:
236 # Slack only allows 250 characters for a topic
237 if len(channel_topic) > 250:
238 channel_topic = channel_topic[:247] + "..."
239 turb.slack_client.conversations_setTopic(channel=channel_id,
242 # Compute the sheet name and set it if it has changed
243 sheet_name = puzzle_sheet_name(puzzle)
245 old_sheet_name = None
247 old_sheet_name = puzzle_sheet_name(old_puzzle)
249 if sheet_name != old_sheet_name:
250 turbot.sheets.renameSheet(turb, puzzle['sheet_url'], sheet_name)
252 # Compute the Slack channel name and set it if it has changed
253 channel_name = puzzle_channel_name(puzzle)
255 old_channel_name = None
257 old_channel_name = puzzle_channel_name(old_puzzle)
259 if channel_name != old_channel_name:
260 turb.slack_client.conversations_rename(