]> git.cworth.org Git - turbot/blob - turbot/puzzle.py
Use common code for setting the channel topic and description
[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_description(puzzle):
276     """Compute the channel description for a puzzle"""
277
278     url = puzzle.get('url', None)
279     sheet_url = puzzle.get('sheet_url', None)
280     tags = puzzle.get('tags', [])
281     state = puzzle.get('state', None)
282
283     description = (
284         "Discussion to solve the puzzle \"{}\".\n".format(puzzle['name'])
285     )
286
287     if url:
288         description += "See the <{}|Original puzzle>\n".format(url)
289
290     if sheet_url:
291         description += (
292             "Actual solving work takes place in the "
293             + "<{}|shared spreadsheet>\n".format(sheet_url)
294         )
295
296     if tags:
297         description += "This puzzle has the following tags: {}\n".format(
298             " ".join(["`{}`".format(t) for t in tags]))
299
300     if state:
301         description += "This puzzle has a state of: {}\n".format(state)
302
303     description += (
304         "You can see a summary of this information at any point "
305         + "by issuing the `/puzzle` command and you can edit any of "
306         + "this information by issuing the `/edit` command"
307     )
308
309     return description
310
311 def puzzle_channel_name(puzzle):
312     """Compute the channel name for a puzzle"""
313
314     round = ''
315     if 'rounds' in puzzle:
316         round = '-' + puzzle_id_from_name(puzzle['rounds'][0])
317
318     meta = ''
319     if puzzle.get('type', 'plain') == 'meta':
320         meta = '--m'
321
322     # Note: We don't use puzzle['puzzle_id'] here because we're keeping
323     # that as a persistent identifier in the database. Instead we
324     # create a new ID-like identifier from the current name.
325     channel_name = "{}{}{}-{}".format(
326         puzzle['hunt_id'],
327         round,
328         meta,
329         puzzle_id_from_name(puzzle['name'])
330     )
331
332     if puzzle['status'] == 'solved':
333         channel_name += "-solved"
334
335     return channel_name
336
337 def puzzle_sheet_name(puzzle):
338     """Compute the sheet name for a puzzle"""
339
340     sheet_name = puzzle['name']
341     if puzzle['status'] == 'solved':
342         sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
343
344     return sheet_name
345
346 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
347
348     channel_id = puzzle['channel_id']
349
350     # Compute the channel topic and set it if it has changed
351     channel_topic = puzzle_channel_topic(puzzle)
352
353     old_channel_topic = None
354     if old_puzzle:
355         old_channel_topic = puzzle_channel_topic(old_puzzle)
356
357     if channel_topic != old_channel_topic:
358         # Slack only allows 250 characters for a topic
359         if len(channel_topic) > 250:
360             channel_topic = channel_topic[:247] + "..."
361         turb.slack_client.conversations_setTopic(channel=channel_id,
362                                                  topic=channel_topic)
363
364     # Compute the channel description and set it if it has changed
365     channel_description = puzzle_channel_description(puzzle)
366
367     old_channel_description = None
368     if old_puzzle:
369         old_channel_description = puzzle_channel_description(old_puzzle)
370
371     if channel_description != old_channel_description:
372         turb.slack_client.conversations_setPurpose(channel=channel_id,
373                                                    purpose=channel_description)
374
375     # Compute the sheet name and set it if it has changed
376     sheet_name = puzzle_sheet_name(puzzle)
377
378     old_sheet_name = None
379     if old_puzzle:
380         old_sheet_name = puzzle_sheet_name(old_puzzle)
381
382     if sheet_name != old_sheet_name:
383         turbot.sheets.rename_spreadsheet(turb, puzzle['sheet_url'], sheet_name)
384
385     # Compute the Slack channel name and set it if it has changed
386     channel_name = puzzle_channel_name(puzzle)
387
388     old_channel_name = None
389     if old_puzzle:
390         old_channel_name = puzzle_channel_name(old_puzzle)
391
392     if channel_name != old_channel_name:
393         turb.slack_client.conversations_rename(
394             channel=channel_id,
395             name=channel_name
396         )
397
398 # A copy deep enough to work for puzzle_update_channel_and_sheet above
399 def puzzle_copy(old_puzzle):
400     new_puzzle = old_puzzle.copy()
401
402     if 'tags' in old_puzzle:
403         new_puzzle['tags'] = old_puzzle['tags'].copy()
404
405     if 'solution' in old_puzzle:
406         new_puzzle['solution'] = old_puzzle['solution'].copy()
407
408     return new_puzzle