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):
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) + "`*"
78 links.append("<{}|Puzzle>".format(url))
80 links.append("<{}|Sheet>".format(sheet_url))
84 state_str = "\n{}".format(state)
86 puzzle_text = "{}{} <{}|{}> ({}){}".format(
87 status_emoji, solution_str,
88 channel_url(channel_id), name,
89 ', '.join(links), state_str
92 # Combining hunt ID and puzzle ID together here is safe because
93 # both IDs are restricted to not contain a hyphen, (see
94 # valid_id_re in interaction.py)
95 hunt_and_puzzle = "{}-{}".format(puzzle['hunt_id'], puzzle['puzzle_id'])
99 section_block(text_block(puzzle_text)),
100 button_block("✏", "edit_puzzle", hunt_and_puzzle)
104 def puzzle_matches_one(puzzle, pattern):
105 """Returns True if this puzzle matches the given string (regexp)
107 A match will be considered on any of puzzle title, round title,
108 puzzle URL, puzzle state, or solution string. The string can
109 include regular expression syntax. Matching is case insensitive.
112 p = re.compile('.*'+pattern+'.*', re.IGNORECASE)
114 if p.match(puzzle['name']):
117 if 'rounds' in puzzle:
118 for round in puzzle['rounds']:
123 if p.match(puzzle['url']):
126 if 'state' in puzzle:
127 if p.match(puzzle['state']):
130 if 'solution' in puzzle:
131 for solution in puzzle['solution']:
132 if p.match(solution):
137 def puzzle_matches_all(puzzle, patterns):
138 """Returns True if this puzzle matches all of the given list of patterns
140 A match will be considered on any of puzzle title, round title,
141 puzzle URL, puzzle state, or solution string. All patterns must
142 match the puzzle somewhere, (that is, there is an implicit logical
143 AND between patterns). Patterns can include regular expression
144 syntax. Matching is case insensitive.
147 for pattern in patterns:
148 if not puzzle_matches_one(puzzle, pattern):
153 def puzzle_id_from_name(name):
154 return re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
156 def puzzle_channel_topic(puzzle):
157 """Compute the channel topic for a puzzle"""
161 if puzzle['status'] == 'solved':
162 topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
164 topic += puzzle['name']
168 url = puzzle.get('url', None)
170 links.append("<{}|Puzzle>".format(url))
172 sheet_url = puzzle.get('sheet_url', None)
174 links.append("<{}|Sheet>".format(sheet_url))
177 topic += "({})".format(', '.join(links))
179 state = puzzle.get('state', None)
181 topic += " {}".format(state)
185 def puzzle_channel_name(puzzle):
186 """Compute the channel name for a puzzle"""
188 # Note: We don't use puzzle['hunt_id'] here because we're keeping
189 # that as a persistent identifier in the database. Instead we
190 # create a new ID-like identifier from the current name.
191 channel_name = "{}-{}".format(
193 puzzle_id_from_name(puzzle['name'])
196 if puzzle['status'] == 'solved':
197 channel_name += "-solved"
201 def puzzle_sheet_name(puzzle):
202 """Compute the sheet name for a puzzle"""
204 sheet_name = puzzle['name']
205 if puzzle['status'] == 'solved':
206 sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
210 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
212 channel_id = puzzle['channel_id']
214 # Compute the channel topic and set it if it has changed
215 channel_topic = puzzle_channel_topic(puzzle)
217 old_channel_topic = None
219 old_channel_topic = puzzle_channel_topic(old_puzzle)
221 if channel_topic != old_channel_topic:
222 # Slack only allows 250 characters for a topic
223 if len(channel_topic) > 250:
224 channel_topic = channel_topic[:247] + "..."
225 turb.slack_client.conversations_setTopic(channel=channel_id,
228 # Compute the sheet name and set it if it has changed
229 sheet_name = puzzle_sheet_name(puzzle)
231 old_sheet_name = None
233 old_sheet_name = puzzle_sheet_name(old_puzzle)
235 if sheet_name != old_sheet_name:
236 turbot.sheets.renameSheet(turb, puzzle['sheet_url'], sheet_name)
238 # Compute the Slack channel name and set it if it has changed
239 channel_name = puzzle_channel_name(puzzle)
241 old_channel_name = None
243 old_channel_name = puzzle_channel_name(old_puzzle)
245 if channel_name != old_channel_name:
246 turb.slack_client.conversations_rename(