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