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