]> git.cworth.org Git - turbot/blob - turbot/puzzle.py
8137de7f87399ab4d047c03aa9067335ececbd74
[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_puzzle_id(turb, hunt_id, puzzle_id):
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': 'puzzle-{}'.format(puzzle_id)
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     # both IDs are restricted to not contain a hyphen, (see
108     # valid_id_re in interaction.py)
109     hunt_and_puzzle = "{}-{}".format(puzzle['hunt_id'], puzzle['puzzle_id'])
110
111     return [
112         accessory_block(
113             section_block(text_block(puzzle_text)),
114             button_block("✏", "edit_puzzle", hunt_and_puzzle)
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_channel_topic(puzzle):
171     """Compute the channel topic for a puzzle"""
172
173     topic = ''
174
175     if puzzle['status'] == 'solved':
176         topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
177
178     topic += puzzle['name']
179
180     links = []
181
182     url = puzzle.get('url', None)
183     if url:
184         links.append("<{}|Puzzle>".format(url))
185
186     sheet_url = puzzle.get('sheet_url', None)
187     if sheet_url:
188         links.append("<{}|Sheet>".format(sheet_url))
189
190     if len(links):
191         topic += "({})".format(', '.join(links))
192
193     state = puzzle.get('state', None)
194     if state:
195         topic += " {}".format(state)
196
197     return topic
198
199 def puzzle_channel_name(puzzle):
200     """Compute the channel name for a puzzle"""
201
202     # Note: We don't use puzzle['puzzle_id'] here because we're keeping
203     # that as a persistent identifier in the database. Instead we
204     # create a new ID-like identifier from the current name.
205     channel_name = "{}-{}".format(
206         puzzle['hunt_id'],
207         puzzle_id_from_name(puzzle['name'])
208     )
209
210     if puzzle['status'] == 'solved':
211         channel_name += "-solved"
212
213     return channel_name
214
215 def puzzle_sheet_name(puzzle):
216     """Compute the sheet name for a puzzle"""
217
218     sheet_name = puzzle['name']
219     if puzzle['status'] == 'solved':
220         sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
221
222     return sheet_name
223
224 def puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=None):
225
226     channel_id = puzzle['channel_id']
227
228     # Compute the channel topic and set it if it has changed
229     channel_topic = puzzle_channel_topic(puzzle)
230
231     old_channel_topic = None
232     if old_puzzle:
233         old_channel_topic = puzzle_channel_topic(old_puzzle)
234
235     if channel_topic != old_channel_topic:
236         # Slack only allows 250 characters for a topic
237         if len(channel_topic) > 250:
238             channel_topic = channel_topic[:247] + "..."
239         turb.slack_client.conversations_setTopic(channel=channel_id,
240                                                  topic=channel_topic)
241
242     # Compute the sheet name and set it if it has changed
243     sheet_name = puzzle_sheet_name(puzzle)
244
245     old_sheet_name = None
246     if old_puzzle:
247         old_sheet_name = puzzle_sheet_name(old_puzzle)
248
249     if sheet_name != old_sheet_name:
250         turbot.sheets.renameSheet(turb, puzzle['sheet_url'], sheet_name)
251
252     # Compute the Slack channel name and set it if it has changed
253     channel_name = puzzle_channel_name(puzzle)
254
255     old_channel_name = None
256     if old_puzzle:
257         old_channel_name = puzzle_channel_name(old_puzzle)
258
259     if channel_name != old_channel_name:
260         turb.slack_client.conversations_rename(
261             channel=channel_id,
262             name=channel_name
263         )