]> git.cworth.org Git - turbot/blob - turbot/puzzle.py
Add a /tag command to add or remove tags from puzzle
[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, or solution string. The string can
133     include regular expression syntax. Matching is case insensitive.
134     """
135
136     p = re.compile('.*'+pattern+'.*', re.IGNORECASE)
137
138     if p.match(puzzle['name']):
139         return True
140
141     if 'rounds' in puzzle:
142         for round in puzzle['rounds']:
143             if p.match(round):
144                 return True
145
146     if 'url' in puzzle:
147         if p.match(puzzle['url']):
148             return True
149
150     if 'state' in puzzle:
151         if p.match(puzzle['state']):
152             return True
153
154     if 'solution' in puzzle:
155         for solution in puzzle['solution']:
156             if p.match(solution):
157                 return True
158
159     return False
160
161 def puzzle_matches_all(puzzle, patterns):
162     """Returns True if this puzzle matches all of the given list of patterns
163
164     A match will be considered on any of puzzle title, round title,
165     puzzle URL, puzzle state, or solution string. All patterns must
166     match the puzzle somewhere, (that is, there is an implicit logical
167     AND between patterns). Patterns can include regular expression
168     syntax. Matching is case insensitive.
169     """
170
171     for pattern in patterns:
172         if not puzzle_matches_one(puzzle, pattern):
173             return False
174
175     return True
176
177 def puzzle_id_from_name(name):
178     return re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
179
180 def puzzle_sort_key(puzzle):
181     """Return an appropriate sort key for a puzzle in the database
182
183     The sort key must start with "puzzle-" to distinguish puzzle items
184     in the database from all non-puzzle items. After that, though, the
185     only requirements are that each puzzle have a unique key and they
186     give us the ordering we want. And for ordering, we want meta puzzles
187     before non-meta puzzles and then alphabetical order by name within
188     each of those groups.
189
190     So puting a "-meta-" prefix in front of the puzzle ID does the trick.
191     """
192
193     return "puzzle-{}{}".format(
194         "-meta-" if puzzle['type'] == "meta" else "",
195         puzzle['puzzle_id']
196     )
197
198 def puzzle_channel_topic(puzzle):
199     """Compute the channel topic for a puzzle"""
200
201     topic = ''
202
203     if puzzle['status'] == 'solved':
204         topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
205
206     topic += puzzle['name']
207
208     links = []
209
210     url = puzzle.get('url', None)
211     if url:
212         links.append("<{}|Puzzle>".format(url))
213
214     sheet_url = puzzle.get('sheet_url', None)
215     if sheet_url:
216         links.append("<{}|Sheet>".format(sheet_url))
217
218     if len(links):
219         topic += "({})".format(', '.join(links))
220
221     tags = puzzle.get('tags', [])
222     if tags:
223         topic += " {}".format(" ".join(["`{}`".format(t) for t in tags]))
224
225     state = puzzle.get('state', None)
226     if state:
227         topic += " {}".format(state)
228
229     return topic
230
231 def puzzle_channel_name(puzzle):
232     """Compute the channel name for a puzzle"""
233
234     # Note: We don't use puzzle['puzzle_id'] here because we're keeping
235     # that as a persistent identifier in the database. Instead we
236     # create a new ID-like identifier from the current name.
237     channel_name = "{}-{}".format(
238         puzzle['hunt_id'],
239         puzzle_id_from_name(puzzle['name'])
240     )
241
242     if puzzle['status'] == 'solved':
243         channel_name += "-solved"
244
245     return channel_name
246
247 def puzzle_sheet_name(puzzle):
248     """Compute the sheet name for a puzzle"""
249
250     sheet_name = puzzle['name']
251     if puzzle['status'] == 'solved':
252         sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
253
254     return sheet_name
255
256 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
257
258     channel_id = puzzle['channel_id']
259
260     # Compute the channel topic and set it if it has changed
261     channel_topic = puzzle_channel_topic(puzzle)
262
263     old_channel_topic = None
264     if old_puzzle:
265         old_channel_topic = puzzle_channel_topic(old_puzzle)
266
267     if channel_topic != old_channel_topic:
268         # Slack only allows 250 characters for a topic
269         if len(channel_topic) > 250:
270             channel_topic = channel_topic[:247] + "..."
271         turb.slack_client.conversations_setTopic(channel=channel_id,
272                                                  topic=channel_topic)
273
274     # Compute the sheet name and set it if it has changed
275     sheet_name = puzzle_sheet_name(puzzle)
276
277     old_sheet_name = None
278     if old_puzzle:
279         old_sheet_name = puzzle_sheet_name(old_puzzle)
280
281     if sheet_name != old_sheet_name:
282         turbot.sheets.renameSheet(turb, puzzle['sheet_url'], sheet_name)
283
284     # Compute the Slack channel name and set it if it has changed
285     channel_name = puzzle_channel_name(puzzle)
286
287     old_channel_name = None
288     if old_puzzle:
289         old_channel_name = puzzle_channel_name(old_puzzle)
290
291     if channel_name != old_channel_name:
292         turb.slack_client.conversations_rename(
293             channel=channel_id,
294             name=channel_name
295         )