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) + "`*"
78 links.append("<{}|Puzzle>".format(url))
80 links.append("<{}|Sheet>".format(sheet_url))
84 state_str = "\n{}".format(state)
87 if include_rounds and 'rounds' in puzzle:
88 rounds = puzzle['rounds']
89 rounds_str = " in round{}: {}".format(
90 "s" if len(rounds) > 1 else "",
94 puzzle_text = "{}{} <{}|{}> ({}){}{}".format(
95 status_emoji, solution_str,
96 channel_url(channel_id), name,
97 ', '.join(links), rounds_str,
101 # Combining hunt ID and puzzle ID together here is safe because
102 # both IDs are restricted to not contain a hyphen, (see
103 # valid_id_re in interaction.py)
104 hunt_and_puzzle = "{}-{}".format(puzzle['hunt_id'], puzzle['puzzle_id'])
108 section_block(text_block(puzzle_text)),
109 button_block("✏", "edit_puzzle", hunt_and_puzzle)
113 def puzzle_matches_one(puzzle, pattern):
114 """Returns True if this puzzle matches the given string (regexp)
116 A match will be considered on any of puzzle title, round title,
117 puzzle URL, puzzle state, or solution string. The string can
118 include regular expression syntax. Matching is case insensitive.
121 p = re.compile('.*'+pattern+'.*', re.IGNORECASE)
123 if p.match(puzzle['name']):
126 if 'rounds' in puzzle:
127 for round in puzzle['rounds']:
132 if p.match(puzzle['url']):
135 if 'state' in puzzle:
136 if p.match(puzzle['state']):
139 if 'solution' in puzzle:
140 for solution in puzzle['solution']:
141 if p.match(solution):
146 def puzzle_matches_all(puzzle, patterns):
147 """Returns True if this puzzle matches all of the given list of patterns
149 A match will be considered on any of puzzle title, round title,
150 puzzle URL, puzzle state, or solution string. All patterns must
151 match the puzzle somewhere, (that is, there is an implicit logical
152 AND between patterns). Patterns can include regular expression
153 syntax. Matching is case insensitive.
156 for pattern in patterns:
157 if not puzzle_matches_one(puzzle, pattern):
162 def puzzle_id_from_name(name):
163 return re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
165 def puzzle_channel_topic(puzzle):
166 """Compute the channel topic for a puzzle"""
170 if puzzle['status'] == 'solved':
171 topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
173 topic += puzzle['name']
177 url = puzzle.get('url', None)
179 links.append("<{}|Puzzle>".format(url))
181 sheet_url = puzzle.get('sheet_url', None)
183 links.append("<{}|Sheet>".format(sheet_url))
186 topic += "({})".format(', '.join(links))
188 state = puzzle.get('state', None)
190 topic += " {}".format(state)
194 def puzzle_channel_name(puzzle):
195 """Compute the channel name for a puzzle"""
197 # Note: We don't use puzzle['hunt_id'] here because we're keeping
198 # that as a persistent identifier in the database. Instead we
199 # create a new ID-like identifier from the current name.
200 channel_name = "{}-{}".format(
202 puzzle_id_from_name(puzzle['name'])
205 if puzzle['status'] == 'solved':
206 channel_name += "-solved"
210 def puzzle_sheet_name(puzzle):
211 """Compute the sheet name for a puzzle"""
213 sheet_name = puzzle['name']
214 if puzzle['status'] == 'solved':
215 sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
219 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
221 channel_id = puzzle['channel_id']
223 # Compute the channel topic and set it if it has changed
224 channel_topic = puzzle_channel_topic(puzzle)
226 old_channel_topic = None
228 old_channel_topic = puzzle_channel_topic(old_puzzle)
230 if channel_topic != old_channel_topic:
231 # Slack only allows 250 characters for a topic
232 if len(channel_topic) > 250:
233 channel_topic = channel_topic[:247] + "..."
234 turb.slack_client.conversations_setTopic(channel=channel_id,
237 # Compute the sheet name and set it if it has changed
238 sheet_name = puzzle_sheet_name(puzzle)
240 old_sheet_name = None
242 old_sheet_name = puzzle_sheet_name(old_puzzle)
244 if sheet_name != old_sheet_name:
245 turbot.sheets.renameSheet(turb, puzzle['sheet_url'], sheet_name)
247 # Compute the Slack channel name and set it if it has changed
248 channel_name = puzzle_channel_name(puzzle)
250 old_channel_name = None
252 old_channel_name = puzzle_channel_name(old_puzzle)
254 if channel_name != old_channel_name:
255 turb.slack_client.conversations_rename(