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