]> git.cworth.org Git - turbot/blob - turbot/puzzle.py
037dd5e01b3599dc7cca0b329d18777c2c532eaf
[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     status_emoji = ''
66     solution_str = ''
67
68     if status == 'solved':
69         status_emoji = ":ballot_box_with_check:"
70     else:
71         status_emoji = ":white_square:"
72
73     if len(solution):
74         solution_str = "*`" + '`, `'.join(solution) + "`*"
75
76     meta_str = ''
77     if puzzle.get('type', 'plain') == 'meta':
78         meta_str = "*META* "
79
80     links = []
81     if url:
82         links.append("<{}|Puzzle>".format(url))
83     if sheet_url:
84         links.append("<{}|Sheet>".format(sheet_url))
85
86     state_str = ''
87     if state:
88         state_str = "\n{}".format(state)
89
90     rounds_str = ''
91     if include_rounds and 'rounds' in puzzle:
92         rounds = puzzle['rounds']
93         rounds_str = " in round{}: {}".format(
94             "s" if len(rounds) > 1 else "",
95             ", ".join(rounds)
96         )
97
98     puzzle_text = "{}{} {}<{}|{}> ({}){}{}".format(
99         status_emoji, solution_str,
100         meta_str,
101         channel_url(channel_id), name,
102         ', '.join(links), rounds_str,
103         state_str
104     )
105
106     # Combining hunt ID and puzzle ID together here is safe because
107     # hunt_id is restricted to not contain a hyphen, (see
108     # valid_id_re in interaction.py)
109     hunt_and_sort_key = "{}-{}".format(puzzle['hunt_id'], puzzle['SK'])
110
111     return [
112         accessory_block(
113             section_block(text_block(puzzle_text)),
114             button_block("✏", "edit_puzzle", hunt_and_sort_key)
115         )
116     ]
117
118 def puzzle_matches_one(puzzle, pattern):
119     """Returns True if this puzzle matches the given string (regexp)
120
121     A match will be considered on any of puzzle title, round title,
122     puzzle URL, puzzle state, or solution string. The string can
123     include regular expression syntax. Matching is case insensitive.
124     """
125
126     p = re.compile('.*'+pattern+'.*', re.IGNORECASE)
127
128     if p.match(puzzle['name']):
129         return True
130
131     if 'rounds' in puzzle:
132         for round in puzzle['rounds']:
133             if p.match(round):
134                 return True
135
136     if 'url' in puzzle:
137         if p.match(puzzle['url']):
138             return True
139
140     if 'state' in puzzle:
141         if p.match(puzzle['state']):
142             return True
143
144     if 'solution' in puzzle:
145         for solution in puzzle['solution']:
146             if p.match(solution):
147                 return True
148
149     return False
150
151 def puzzle_matches_all(puzzle, patterns):
152     """Returns True if this puzzle matches all of the given list of patterns
153
154     A match will be considered on any of puzzle title, round title,
155     puzzle URL, puzzle state, or solution string. All patterns must
156     match the puzzle somewhere, (that is, there is an implicit logical
157     AND between patterns). Patterns can include regular expression
158     syntax. Matching is case insensitive.
159     """
160
161     for pattern in patterns:
162         if not puzzle_matches_one(puzzle, pattern):
163             return False
164
165     return True
166
167 def puzzle_id_from_name(name):
168     return re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
169
170 def puzzle_sort_key(puzzle):
171     """Return an appropriate sort key for a puzzle in the database
172
173     The sort key must start with "puzzle-" to distinguish puzzle items
174     in the database from all non-puzzle items. After that, though, the
175     only requirements are that each puzzle have a unique key and they
176     give us the ordering we want. And for ordering, we want meta puzzles
177     before non-meta puzzles and then alphabetical order by name within
178     each of those groups.
179
180     So puting a "-meta-" prefix in front of the puzzle ID does the trick.
181     """
182
183     return "puzzle-{}{}".format(
184         "-meta-" if puzzle['type'] == "meta" else "",
185         puzzle['puzzle_id']
186     )
187
188 def puzzle_channel_topic(puzzle):
189     """Compute the channel topic for a puzzle"""
190
191     topic = ''
192
193     if puzzle['status'] == 'solved':
194         topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
195
196     topic += puzzle['name']
197
198     links = []
199
200     url = puzzle.get('url', None)
201     if url:
202         links.append("<{}|Puzzle>".format(url))
203
204     sheet_url = puzzle.get('sheet_url', None)
205     if sheet_url:
206         links.append("<{}|Sheet>".format(sheet_url))
207
208     if len(links):
209         topic += "({})".format(', '.join(links))
210
211     state = puzzle.get('state', None)
212     if state:
213         topic += " {}".format(state)
214
215     return topic
216
217 def puzzle_channel_name(puzzle):
218     """Compute the channel name for a puzzle"""
219
220     # Note: We don't use puzzle['puzzle_id'] here because we're keeping
221     # that as a persistent identifier in the database. Instead we
222     # create a new ID-like identifier from the current name.
223     channel_name = "{}-{}".format(
224         puzzle['hunt_id'],
225         puzzle_id_from_name(puzzle['name'])
226     )
227
228     if puzzle['status'] == 'solved':
229         channel_name += "-solved"
230
231     return channel_name
232
233 def puzzle_sheet_name(puzzle):
234     """Compute the sheet name for a puzzle"""
235
236     sheet_name = puzzle['name']
237     if puzzle['status'] == 'solved':
238         sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
239
240     return sheet_name
241
242 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
243
244     channel_id = puzzle['channel_id']
245
246     # Compute the channel topic and set it if it has changed
247     channel_topic = puzzle_channel_topic(puzzle)
248
249     old_channel_topic = None
250     if old_puzzle:
251         old_channel_topic = puzzle_channel_topic(old_puzzle)
252
253     if channel_topic != old_channel_topic:
254         # Slack only allows 250 characters for a topic
255         if len(channel_topic) > 250:
256             channel_topic = channel_topic[:247] + "..."
257         turb.slack_client.conversations_setTopic(channel=channel_id,
258                                                  topic=channel_topic)
259
260     # Compute the sheet name and set it if it has changed
261     sheet_name = puzzle_sheet_name(puzzle)
262
263     old_sheet_name = None
264     if old_puzzle:
265         old_sheet_name = puzzle_sheet_name(old_puzzle)
266
267     if sheet_name != old_sheet_name:
268         turbot.sheets.renameSheet(turb, puzzle['sheet_url'], sheet_name)
269
270     # Compute the Slack channel name and set it if it has changed
271     channel_name = puzzle_channel_name(puzzle)
272
273     old_channel_name = None
274     if old_puzzle:
275         old_channel_name = puzzle_channel_name(old_puzzle)
276
277     if channel_name != old_channel_name:
278         turb.slack_client.conversations_rename(
279             channel=channel_id,
280             name=channel_name
281         )