]> git.cworth.org Git - turbot/blob - turbot/puzzle.py
b86a529ed47b9d5c64e6d4660104bcbfeb58a0e9
[turbot] / turbot / puzzle.py
1 from turbot.blocks import (
2     section_block, text_block, button_block, accessory_block
3 )
4 from turbot.channel import channel_url
5 from boto3.dynamodb.conditions import Key
6 import turbot.sheets
7 import re
8
9 def find_puzzle_for_sort_key(turb, hunt_id, sort_key):
10     """Given a hunt_id and puzzle_id, return that puzzle
11
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.
15     """
16
17     response = turb.table.get_item(
18         Key={
19             'hunt_id': hunt_id,
20             'SK': sort_key,
21         })
22
23     if 'Item' in response:
24         return response['Item']
25     else:
26         return None
27
28 def find_puzzle_for_url(turb, hunt_id, url):
29     """Given a hunt_id and URL, return the puzzle with that URL
30
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
33     the database.
34     """
35
36     response = turb.table.query(
37         IndexName='url_index',
38         KeyConditionExpression=(
39             Key('hunt_id').eq(hunt_id) &
40             Key('url').eq(url)
41         )
42     )
43
44     if response['Count'] == 0:
45         return None
46
47     return response['Items'][0]
48
49 def puzzle_blocks(puzzle, include_rounds=False):
50     """Generate Slack blocks for a puzzle
51
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.).
56     """
57
58     name = puzzle['name']
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     status_emoji = ''
66     solution_str = ''
67
68     if status == 'solved':
69         status_emoji = ":ballot_box_with_check:"
70     else:
71         status_emoji = ":white_square:"
72
73     if len(solution):
74         solution_str = "*`" + '`, `'.join(solution) + "`*"
75
76     meta_str = ''
77     if puzzle.get('type', 'plain') == 'meta':
78         meta_str = "*META* "
79
80     links = []
81     if url:
82         links.append("<{}|Puzzle>".format(url))
83     if sheet_url:
84         links.append("<{}|Sheet>".format(sheet_url))
85
86     state_str = ''
87     if state:
88         state_str = "\n{}".format(state)
89
90     rounds_str = ''
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 "",
95             ", ".join(rounds)
96         )
97
98     puzzle_text = "{} {}<{}|{}> {} ({}){}{}".format(
99         status_emoji,
100         meta_str,
101         channel_url(channel_id), name,
102         solution_str,
103         ', '.join(links), rounds_str,
104         state_str
105     )
106
107     # Combining hunt ID and puzzle ID together here is safe because
108     # hunt_id is restricted to not contain a hyphen, (see
109     # valid_id_re in interaction.py)
110     hunt_and_sort_key = "{}-{}".format(puzzle['hunt_id'], puzzle['SK'])
111
112     return [
113         accessory_block(
114             section_block(text_block(puzzle_text)),
115             button_block("✏", "edit_puzzle", hunt_and_sort_key)
116         )
117     ]
118
119 def puzzle_matches_one(puzzle, pattern):
120     """Returns True if this puzzle matches the given string (regexp)
121
122     A match will be considered on any of puzzle title, round title,
123     puzzle URL, puzzle state, or solution string. The string can
124     include regular expression syntax. Matching is case insensitive.
125     """
126
127     p = re.compile('.*'+pattern+'.*', re.IGNORECASE)
128
129     if p.match(puzzle['name']):
130         return True
131
132     if 'rounds' in puzzle:
133         for round in puzzle['rounds']:
134             if p.match(round):
135                 return True
136
137     if 'url' in puzzle:
138         if p.match(puzzle['url']):
139             return True
140
141     if 'state' in puzzle:
142         if p.match(puzzle['state']):
143             return True
144
145     if 'solution' in puzzle:
146         for solution in puzzle['solution']:
147             if p.match(solution):
148                 return True
149
150     return False
151
152 def puzzle_matches_all(puzzle, patterns):
153     """Returns True if this puzzle matches all of the given list of patterns
154
155     A match will be considered on any of puzzle title, round title,
156     puzzle URL, puzzle state, or solution string. All patterns must
157     match the puzzle somewhere, (that is, there is an implicit logical
158     AND between patterns). Patterns can include regular expression
159     syntax. Matching is case insensitive.
160     """
161
162     for pattern in patterns:
163         if not puzzle_matches_one(puzzle, pattern):
164             return False
165
166     return True
167
168 def puzzle_id_from_name(name):
169     return re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
170
171 def puzzle_sort_key(puzzle):
172     """Return an appropriate sort key for a puzzle in the database
173
174     The sort key must start with "puzzle-" to distinguish puzzle items
175     in the database from all non-puzzle items. After that, though, the
176     only requirements are that each puzzle have a unique key and they
177     give us the ordering we want. And for ordering, we want meta puzzles
178     before non-meta puzzles and then alphabetical order by name within
179     each of those groups.
180
181     So puting a "-meta-" prefix in front of the puzzle ID does the trick.
182     """
183
184     return "puzzle-{}{}".format(
185         "-meta-" if puzzle['type'] == "meta" else "",
186         puzzle['puzzle_id']
187     )
188
189 def puzzle_channel_topic(puzzle):
190     """Compute the channel topic for a puzzle"""
191
192     topic = ''
193
194     if puzzle['status'] == 'solved':
195         topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
196
197     topic += puzzle['name']
198
199     links = []
200
201     url = puzzle.get('url', None)
202     if url:
203         links.append("<{}|Puzzle>".format(url))
204
205     sheet_url = puzzle.get('sheet_url', None)
206     if sheet_url:
207         links.append("<{}|Sheet>".format(sheet_url))
208
209     if len(links):
210         topic += "({})".format(', '.join(links))
211
212     state = puzzle.get('state', None)
213     if state:
214         topic += " {}".format(state)
215
216     return topic
217
218 def puzzle_channel_name(puzzle):
219     """Compute the channel name for a puzzle"""
220
221     # Note: We don't use puzzle['puzzle_id'] here because we're keeping
222     # that as a persistent identifier in the database. Instead we
223     # create a new ID-like identifier from the current name.
224     channel_name = "{}-{}".format(
225         puzzle['hunt_id'],
226         puzzle_id_from_name(puzzle['name'])
227     )
228
229     if puzzle['status'] == 'solved':
230         channel_name += "-solved"
231
232     return channel_name
233
234 def puzzle_sheet_name(puzzle):
235     """Compute the sheet name for a puzzle"""
236
237     sheet_name = puzzle['name']
238     if puzzle['status'] == 'solved':
239         sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
240
241     return sheet_name
242
243 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
244
245     channel_id = puzzle['channel_id']
246
247     # Compute the channel topic and set it if it has changed
248     channel_topic = puzzle_channel_topic(puzzle)
249
250     old_channel_topic = None
251     if old_puzzle:
252         old_channel_topic = puzzle_channel_topic(old_puzzle)
253
254     if channel_topic != old_channel_topic:
255         # Slack only allows 250 characters for a topic
256         if len(channel_topic) > 250:
257             channel_topic = channel_topic[:247] + "..."
258         turb.slack_client.conversations_setTopic(channel=channel_id,
259                                                  topic=channel_topic)
260
261     # Compute the sheet name and set it if it has changed
262     sheet_name = puzzle_sheet_name(puzzle)
263
264     old_sheet_name = None
265     if old_puzzle:
266         old_sheet_name = puzzle_sheet_name(old_puzzle)
267
268     if sheet_name != old_sheet_name:
269         turbot.sheets.renameSheet(turb, puzzle['sheet_url'], sheet_name)
270
271     # Compute the Slack channel name and set it if it has changed
272     channel_name = puzzle_channel_name(puzzle)
273
274     old_channel_name = None
275     if old_puzzle:
276         old_channel_name = puzzle_channel_name(old_puzzle)
277
278     if channel_name != old_channel_name:
279         turb.slack_client.conversations_rename(
280             channel=channel_id,
281             name=channel_name
282         )