]> git.cworth.org Git - turbot/blob - turbot/puzzle.py
Add notes on how to update the Google sheets credentials
[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 round_id_from_name(name):
227     """Normalize and abbreviate round name for use as a prefix
228        in a channel name."""
229
230     return re.sub(r'[^a-zA-Z0-9_]', '', name).lower()[:7]
231
232 def puzzle_sort_key(puzzle):
233     """Return an appropriate sort key for a puzzle in the database
234
235     The sort key must start with "puzzle-" to distinguish puzzle items
236     in the database from all non-puzzle items. After that, though, the
237     only requirements are that each puzzle have a unique key and they
238     give us the ordering we want. And for ordering, we want meta puzzles
239     before non-meta puzzles and then alphabetical order by name within
240     each of those groups.
241
242     So puting a "-meta-" prefix in front of the puzzle ID does the trick.
243     """
244
245     return "puzzle-{}{}".format(
246         "-meta-" if puzzle['type'] == "meta" else "",
247         puzzle['puzzle_id']
248     )
249
250 def puzzle_channel_topic(puzzle):
251     """Compute the channel topic for a puzzle"""
252
253     topic = ''
254
255     if puzzle['status'] == 'solved':
256         topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
257
258     links = []
259
260     url = puzzle.get('url', None)
261     if url:
262         links.append("<{}|Puzzle>".format(url))
263
264     sheet_url = puzzle.get('sheet_url', None)
265     if sheet_url:
266         links.append("<{}|Sheet>".format(sheet_url))
267
268     if len(links):
269         topic += "({}) ".format(', '.join(links))
270
271     tags = puzzle.get('tags', [])
272     if tags:
273         topic += " {}".format(" ".join(["`{}`".format(t) for t in tags]))
274
275     state = puzzle.get('state', None)
276     if state:
277         topic += " {}".format(state)
278
279     return topic
280
281 def puzzle_channel_description(puzzle):
282     """Compute the channel description for a puzzle"""
283
284     url = puzzle.get('url', None)
285     sheet_url = puzzle.get('sheet_url', None)
286     tags = puzzle.get('tags', [])
287     state = puzzle.get('state', None)
288
289     description = (
290         "Puzzle: \"{}\".\n".format(puzzle['name'])
291     )
292
293     links = ''
294     if url:
295         links += " <{}|Original puzzle> ".format(url)
296
297     if sheet_url:
298         links += " <{}|Sheet>".format(sheet_url)
299
300     if links:
301         description += "Links:{}\n".format(links)
302
303     if tags:
304         description += "Tags: {}\n".format(
305             " ".join(["`{}`".format(t) for t in tags]))
306
307     if state:
308         description += "State: {}\n".format(state)
309
310     return description
311
312 def puzzle_channel_name(puzzle):
313     """Compute the channel name for a puzzle"""
314
315     round = ''
316     if 'rounds' in puzzle:
317         round = '-' + round_id_from_name(puzzle['rounds'][0])
318
319     meta = ''
320     if puzzle.get('type', 'plain') == 'meta':
321         meta = '--m'
322
323     # Note: We don't use puzzle['puzzle_id'] here because we're keeping
324     # that as a persistent identifier in the database. Instead we
325     # create a new ID-like identifier from the current name.
326     channel_name = "{}{}{}-{}".format(
327         puzzle['hunt_id'],
328         round,
329         meta,
330         puzzle_id_from_name(puzzle['name'])
331     )
332
333     if puzzle['status'] == 'solved':
334         channel_name += "-solved"
335
336     return channel_name
337
338 def puzzle_sheet_name(puzzle):
339     """Compute the sheet name for a puzzle"""
340
341     sheet_name = puzzle['name']
342     if puzzle['status'] == 'solved':
343         sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
344
345     return sheet_name
346
347 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
348
349     channel_id = puzzle['channel_id']
350
351     # Compute the channel topic and set it if it has changed
352     channel_topic = puzzle_channel_topic(puzzle)
353
354     old_channel_topic = None
355     if old_puzzle:
356         old_channel_topic = puzzle_channel_topic(old_puzzle)
357
358     if channel_topic != old_channel_topic:
359         # Slack only allows 250 characters for a topic
360         if len(channel_topic) > 250:
361             channel_topic = channel_topic[:247] + "..."
362         turb.slack_client.conversations_setTopic(channel=channel_id,
363                                                  topic=channel_topic)
364
365     # Compute the channel description and set it if it has changed
366     channel_description = puzzle_channel_description(puzzle)
367
368     old_channel_description = None
369     if old_puzzle:
370         old_channel_description = puzzle_channel_description(old_puzzle)
371
372     if channel_description != old_channel_description:
373         # Slack also only allows 250 characters for a description
374         if len(channel_description) > 250:
375             channel_description = channel_description[:247] + "..."
376         turb.slack_client.conversations_setPurpose(channel=channel_id,
377                                                    purpose=channel_description)
378
379     # Compute the sheet name and set it if it has changed
380     sheet_name = puzzle_sheet_name(puzzle)
381
382     old_sheet_name = None
383     if old_puzzle:
384         old_sheet_name = puzzle_sheet_name(old_puzzle)
385
386     if sheet_name != old_sheet_name:
387         turbot.sheets.rename_spreadsheet(turb, puzzle['sheet_url'], sheet_name)
388
389     # Compute the Slack channel name and set it if it has changed
390     channel_name = puzzle_channel_name(puzzle)
391
392     old_channel_name = None
393     if old_puzzle:
394         old_channel_name = puzzle_channel_name(old_puzzle)
395
396     if channel_name != old_channel_name:
397         turb.slack_client.conversations_rename(
398             channel=channel_id,
399             name=channel_name
400         )
401
402 # A copy deep enough to work for puzzle_update_channel_and_sheet above
403 def puzzle_copy(old_puzzle):
404     new_puzzle = old_puzzle.copy()
405
406     if 'tags' in old_puzzle:
407         new_puzzle['tags'] = old_puzzle['tags'].copy()
408
409     if 'solution' in old_puzzle:
410         new_puzzle['solution'] = old_puzzle['solution'].copy()
411
412     return new_puzzle