]> git.cworth.org Git - turbot/blob - turbot/puzzle.py
abfe60b505308874109938f48f234cc2791c19a0
[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     topic += puzzle['name']
253
254     links = []
255
256     url = puzzle.get('url', None)
257     if url:
258         links.append("<{}|Puzzle>".format(url))
259
260     sheet_url = puzzle.get('sheet_url', None)
261     if sheet_url:
262         links.append("<{}|Sheet>".format(sheet_url))
263
264     if len(links):
265         topic += "({})".format(', '.join(links))
266
267     tags = puzzle.get('tags', [])
268     if tags:
269         topic += " {}".format(" ".join(["`{}`".format(t) for t in tags]))
270
271     state = puzzle.get('state', None)
272     if state:
273         topic += " {}".format(state)
274
275     return topic
276
277 def puzzle_channel_name(puzzle):
278     """Compute the channel name for a puzzle"""
279
280     round = ''
281     if 'rounds' in puzzle:
282         round = '-' + puzzle_id_from_name(puzzle['rounds'][0])
283
284     meta = ''
285     if puzzle.get('type', 'plain') == 'meta':
286         meta = '--m'
287
288     # Note: We don't use puzzle['puzzle_id'] here because we're keeping
289     # that as a persistent identifier in the database. Instead we
290     # create a new ID-like identifier from the current name.
291     channel_name = "{}{}{}-{}".format(
292         puzzle['hunt_id'],
293         round,
294         meta,
295         puzzle_id_from_name(puzzle['name'])
296     )
297
298     if puzzle['status'] == 'solved':
299         channel_name += "-solved"
300
301     return channel_name
302
303 def puzzle_sheet_name(puzzle):
304     """Compute the sheet name for a puzzle"""
305
306     sheet_name = puzzle['name']
307     if puzzle['status'] == 'solved':
308         sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
309
310     return sheet_name
311
312 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
313
314     channel_id = puzzle['channel_id']
315
316     # Compute the channel topic and set it if it has changed
317     channel_topic = puzzle_channel_topic(puzzle)
318
319     old_channel_topic = None
320     if old_puzzle:
321         old_channel_topic = puzzle_channel_topic(old_puzzle)
322
323     if channel_topic != old_channel_topic:
324         # Slack only allows 250 characters for a topic
325         if len(channel_topic) > 250:
326             channel_topic = channel_topic[:247] + "..."
327         turb.slack_client.conversations_setTopic(channel=channel_id,
328                                                  topic=channel_topic)
329
330     # Compute the sheet name and set it if it has changed
331     sheet_name = puzzle_sheet_name(puzzle)
332
333     old_sheet_name = None
334     if old_puzzle:
335         old_sheet_name = puzzle_sheet_name(old_puzzle)
336
337     if sheet_name != old_sheet_name:
338         turbot.sheets.rename_spreadsheet(turb, puzzle['sheet_url'], sheet_name)
339
340     # Compute the Slack channel name and set it if it has changed
341     channel_name = puzzle_channel_name(puzzle)
342
343     old_channel_name = None
344     if old_puzzle:
345         old_channel_name = puzzle_channel_name(old_puzzle)
346
347     if channel_name != old_channel_name:
348         turb.slack_client.conversations_rename(
349             channel=channel_id,
350             name=channel_name
351         )
352
353 # A copy deep enough to work for puzzle_update_channel_and_sheet above
354 def puzzle_copy(old_puzzle):
355     new_puzzle = old_puzzle.copy()
356
357     if 'tags' in old_puzzle:
358         new_puzzle['tags'] = old_puzzle['tags'].copy()
359
360     return new_puzzle