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