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