]> git.cworth.org Git - turbot/blob - turbot/puzzle.py
a6d3ffd809b2af59e44f564e1ae78092588d7f4c
[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 sort_key, return that puzzle
11
12     Returns None if no puzzle with the given hunt_id and sort_key
13     exists in the database, otherwise a dictionary with all fields
14     from the puzzle's row in the database.
15
16     Note: The sort_key is a modified version of the puzzle_id, (used
17     to make metapuzzles appear in the ordering before non-metapuzzles).
18     If you've been handed a sort_key, then looking up a puzzle by
19     sort_key is the right thing to do. But if you instead have just
20     a puzzle_id, see find_puzzle_for_puzzle_id rather than trying
21     to convert the puzzle_id into a sort_key to use this function.
22     """
23
24     response = turb.table.get_item(
25         Key={
26             'hunt_id': hunt_id,
27             'SK': sort_key,
28         })
29
30     if 'Item' in response:
31         return response['Item']
32     else:
33         return None
34
35 def find_puzzle_for_puzzle_id(turb, hunt_id, puzzle_id):
36     """Given a hunt_id and puzzle_id, return that puzzle
37
38     Returns None if no puzzle with the given hunt_id and puzzle_id
39     exists in the database, otherwise a dictionary with all fields
40     from the puzzle's row in the database.
41
42     Note: The sort_key is a modified version of the puzzle_id, (used
43     to make metapuzzles appear in the ordering before non-metapuzzles).
44     If you've been handed a sort_key, then looking up a puzzle by
45     sort_key is the right thing to do. But if you instead have just
46     a puzzle_id, see find_puzzle_for_puzzle_id rather than trying
47     to convert the puzzle_id into a sort_key to use this function.
48     """
49
50     response = turb.table.query(
51         IndexName='puzzle_id_index',
52         KeyConditionExpression=(
53             Key('hunt_id').eq(hunt_id) &
54             Key('puzzle_id').eq(puzzle_id)
55         )
56     )
57
58     if response['Count'] == 0:
59         return None
60
61     return response['Items'][0]
62
63 def find_puzzle_for_url(turb, hunt_id, url):
64     """Given a hunt_id and URL, return the puzzle with that URL
65
66     Returns None if no puzzle with the given URL exists in the database,
67     otherwise a dictionary with all fields from the puzzle's row in
68     the database.
69     """
70
71     response = turb.table.query(
72         IndexName='url_index',
73         KeyConditionExpression=(
74             Key('hunt_id').eq(hunt_id) &
75             Key('url').eq(url)
76         )
77     )
78
79     if response['Count'] == 0:
80         return None
81
82     return response['Items'][0]
83
84 def puzzle_blocks(puzzle, include_rounds=False):
85     """Generate Slack blocks for a puzzle
86
87     The puzzle argument should be a dictionary as returned from the
88     database. The return value can be used in a Slack command
89     expecting blocks to provide all the details of a puzzle, (its
90     state, solution, links to channel and sheet, etc.).
91     """
92
93     name = puzzle['name']
94     status = puzzle['status']
95     solution = puzzle['solution']
96     channel_id = puzzle['channel_id']
97     url = puzzle.get('url', None)
98     sheet_url = puzzle.get('sheet_url', None)
99     state = puzzle.get('state', None)
100     tags = puzzle.get('tags', [])
101     status_emoji = ''
102     solution_str = ''
103
104     if status == 'solved':
105         status_emoji = ":ballot_box_with_check:"
106     else:
107         status_emoji = ":white_square:"
108
109     if len(solution):
110         solution_str = "*`" + '`, `'.join(solution) + "`*"
111
112     meta_str = ''
113     if puzzle.get('type', 'plain') == 'meta':
114         meta_str = "*META* "
115
116     links = []
117     if url:
118         links.append("<{}|Puzzle>".format(url))
119     if sheet_url:
120         links.append("<{}|Sheet>".format(sheet_url))
121
122     state_str = ''
123     if state:
124         state_str = " State: {}".format(state)
125
126     tags_str = ''
127     if tags:
128         tags_str = " Tags: "+" ".join(["`{}`".format(tag) for tag in tags])
129
130     extra_str = ''
131     if state_str or tags_str:
132         extra_str = "\n{}{}".format(tags_str, state_str)
133
134     rounds_str = ''
135     if include_rounds and 'rounds' in puzzle:
136         rounds = puzzle['rounds']
137         rounds_str = " in round{}: {}".format(
138             "s" if len(rounds) > 1 else "",
139             ", ".join(rounds)
140         )
141
142     puzzle_text = "{} {}<{}|{}> {} ({}){}{}".format(
143         status_emoji,
144         meta_str,
145         channel_url(channel_id), name,
146         solution_str,
147         ', '.join(links), rounds_str,
148         extra_str
149     )
150
151     # Combining hunt ID and puzzle ID together here is safe because
152     # hunt_id is restricted to not contain a hyphen, (see
153     # valid_id_re in interaction.py)
154     hunt_and_sort_key = "{}-{}".format(puzzle['hunt_id'], puzzle['SK'])
155
156     return [
157         accessory_block(
158             section_block(text_block(puzzle_text)),
159             button_block("✏", "edit_puzzle", hunt_and_sort_key)
160         )
161     ]
162
163 def puzzle_matches_one(puzzle, pattern):
164     """Returns True if this puzzle matches the given string (regexp)
165
166     A match will be considered on any of puzzle title, round title,
167     puzzle URL, puzzle state, puzzle type, tags, or solution
168     string. The string can include regular expression syntax. Matching
169     is case insensitive.
170     """
171
172     p = re.compile('.*'+pattern+'.*', re.IGNORECASE)
173
174     if p.match(puzzle['name']):
175         return True
176
177     if 'rounds' in puzzle:
178         for round in puzzle['rounds']:
179             if p.match(round):
180                 return True
181
182     if 'url' in puzzle:
183         if p.match(puzzle['url']):
184             return True
185
186     if 'state' in puzzle:
187         if p.match(puzzle['state']):
188             return True
189
190     if 'type' in puzzle:
191         if p.match(puzzle['type']):
192             return True
193
194     if 'solution' in puzzle:
195         for solution in puzzle['solution']:
196             if p.match(solution):
197                 return True
198
199     if 'tags' in puzzle:
200         for tag in puzzle['tags']:
201             if p.match(tag):
202                 return True
203
204     return False
205
206 def puzzle_matches_all(puzzle, patterns):
207     """Returns True if this puzzle matches all of the given list of patterns
208
209     A match will be considered on any of puzzle title, round title,
210     puzzle URL, puzzle state, puzzle types, tags, or solution
211     string. All patterns must match the puzzle somewhere, (that is,
212     there is an implicit logical AND between patterns). Patterns can
213     include regular expression syntax. Matching is case insensitive.
214
215     """
216
217     for pattern in patterns:
218         if not puzzle_matches_one(puzzle, pattern):
219             return False
220
221     return True
222
223 def puzzle_id_from_name(name):
224     return re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
225
226 def puzzle_sort_key(puzzle):
227     """Return an appropriate sort key for a puzzle in the database
228
229     The sort key must start with "puzzle-" to distinguish puzzle items
230     in the database from all non-puzzle items. After that, though, the
231     only requirements are that each puzzle have a unique key and they
232     give us the ordering we want. And for ordering, we want meta puzzles
233     before non-meta puzzles and then alphabetical order by name within
234     each of those groups.
235
236     So puting a "-meta-" prefix in front of the puzzle ID does the trick.
237     """
238
239     return "puzzle-{}{}".format(
240         "-meta-" if puzzle['type'] == "meta" else "",
241         puzzle['puzzle_id']
242     )
243
244 def puzzle_channel_topic(puzzle):
245     """Compute the channel topic for a puzzle"""
246
247     topic = ''
248
249     if puzzle['status'] == 'solved':
250         topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
251
252     links = []
253
254     url = puzzle.get('url', None)
255     if url:
256         links.append("<{}|Puzzle>".format(url))
257
258     sheet_url = puzzle.get('sheet_url', None)
259     if sheet_url:
260         links.append("<{}|Sheet>".format(sheet_url))
261
262     if len(links):
263         topic += "({}) ".format(', '.join(links))
264
265     tags = puzzle.get('tags', [])
266     if tags:
267         topic += " {}".format(" ".join(["`{}`".format(t) for t in tags]))
268
269     state = puzzle.get('state', None)
270     if state:
271         topic += " {}".format(state)
272
273     return topic
274
275 def puzzle_channel_name(puzzle):
276     """Compute the channel name for a puzzle"""
277
278     round = ''
279     if 'rounds' in puzzle:
280         round = '-' + puzzle_id_from_name(puzzle['rounds'][0])
281
282     meta = ''
283     if puzzle.get('type', 'plain') == 'meta':
284         meta = '--m'
285
286     # Note: We don't use puzzle['puzzle_id'] here because we're keeping
287     # that as a persistent identifier in the database. Instead we
288     # create a new ID-like identifier from the current name.
289     channel_name = "{}{}{}-{}".format(
290         puzzle['hunt_id'],
291         round,
292         meta,
293         puzzle_id_from_name(puzzle['name'])
294     )
295
296     if puzzle['status'] == 'solved':
297         channel_name += "-solved"
298
299     return channel_name
300
301 def puzzle_sheet_name(puzzle):
302     """Compute the sheet name for a puzzle"""
303
304     sheet_name = puzzle['name']
305     if puzzle['status'] == 'solved':
306         sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
307
308     return sheet_name
309
310 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
311
312     channel_id = puzzle['channel_id']
313
314     # Compute the channel topic and set it if it has changed
315     channel_topic = puzzle_channel_topic(puzzle)
316
317     old_channel_topic = None
318     if old_puzzle:
319         old_channel_topic = puzzle_channel_topic(old_puzzle)
320
321     if channel_topic != old_channel_topic:
322         # Slack only allows 250 characters for a topic
323         if len(channel_topic) > 250:
324             channel_topic = channel_topic[:247] + "..."
325         turb.slack_client.conversations_setTopic(channel=channel_id,
326                                                  topic=channel_topic)
327
328     # Compute the sheet name and set it if it has changed
329     sheet_name = puzzle_sheet_name(puzzle)
330
331     old_sheet_name = None
332     if old_puzzle:
333         old_sheet_name = puzzle_sheet_name(old_puzzle)
334
335     if sheet_name != old_sheet_name:
336         turbot.sheets.rename_spreadsheet(turb, puzzle['sheet_url'], sheet_name)
337
338     # Compute the Slack channel name and set it if it has changed
339     channel_name = puzzle_channel_name(puzzle)
340
341     old_channel_name = None
342     if old_puzzle:
343         old_channel_name = puzzle_channel_name(old_puzzle)
344
345     if channel_name != old_channel_name:
346         turb.slack_client.conversations_rename(
347             channel=channel_id,
348             name=channel_name
349         )
350
351 # A copy deep enough to work for puzzle_update_channel_and_sheet above
352 def puzzle_copy(old_puzzle):
353     new_puzzle = old_puzzle.copy()
354
355     if 'tags' in old_puzzle:
356         new_puzzle['tags'] = old_puzzle['tags'].copy()
357
358     if 'solution' in old_puzzle:
359         new_puzzle['solution'] = old_puzzle['solution'].copy()
360
361     return new_puzzle