]> git.cworth.org Git - turbot/blob - turbot/puzzle.py
08e86218738bbfba6cdeff598ded4acdfb758cec
[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     round = ''
246     if 'rounds' in puzzle:
247         round = '-' + puzzle_id_from_name(puzzle['rounds'][0])
248
249     meta = ''
250     if puzzle.get('type', 'plain') == 'meta':
251         meta = '-m'
252
253     # Note: We don't use puzzle['puzzle_id'] here because we're keeping
254     # that as a persistent identifier in the database. Instead we
255     # create a new ID-like identifier from the current name.
256     channel_name = "{}{}{}-{}".format(
257         puzzle['hunt_id'],
258         round,
259         meta,
260         puzzle_id_from_name(puzzle['name'])
261     )
262
263     if puzzle['status'] == 'solved':
264         channel_name += "-solved"
265
266     return channel_name
267
268 def puzzle_sheet_name(puzzle):
269     """Compute the sheet name for a puzzle"""
270
271     sheet_name = puzzle['name']
272     if puzzle['status'] == 'solved':
273         sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
274
275     return sheet_name
276
277 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
278
279     channel_id = puzzle['channel_id']
280
281     # Compute the channel topic and set it if it has changed
282     channel_topic = puzzle_channel_topic(puzzle)
283
284     old_channel_topic = None
285     if old_puzzle:
286         old_channel_topic = puzzle_channel_topic(old_puzzle)
287
288     if channel_topic != old_channel_topic:
289         # Slack only allows 250 characters for a topic
290         if len(channel_topic) > 250:
291             channel_topic = channel_topic[:247] + "..."
292         turb.slack_client.conversations_setTopic(channel=channel_id,
293                                                  topic=channel_topic)
294
295     # Compute the sheet name and set it if it has changed
296     sheet_name = puzzle_sheet_name(puzzle)
297
298     old_sheet_name = None
299     if old_puzzle:
300         old_sheet_name = puzzle_sheet_name(old_puzzle)
301
302     if sheet_name != old_sheet_name:
303         turbot.sheets.rename_spreadsheet(turb, puzzle['sheet_url'], sheet_name)
304
305     # Compute the Slack channel name and set it if it has changed
306     channel_name = puzzle_channel_name(puzzle)
307
308     old_channel_name = None
309     if old_puzzle:
310         old_channel_name = puzzle_channel_name(old_puzzle)
311
312     if channel_name != old_channel_name:
313         turb.slack_client.conversations_rename(
314             channel=channel_id,
315             name=channel_name
316         )
317
318 # A copy deep enough to work for puzzle_update_channel_and_sheet above
319 def puzzle_copy(old_puzzle):
320     new_puzzle = old_puzzle.copy()
321
322     if 'tags' in old_puzzle:
323         new_puzzle['tags'] = old_puzzle['tags'].copy()
324
325     return new_puzzle