]> git.cworth.org Git - turbot/blob - turbot/puzzle.py
Unify code to rename channel, set channel description, and rename sheet
[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_update_channel_and_sheet(turb, puzzle):
157
158     channel_id = puzzle['channel_id']
159     name = puzzle['name']
160     url = puzzle.get('url', None)
161     sheet_url = puzzle.get('sheet_url', None)
162     state = puzzle.get('state', None)
163     status = puzzle['status']
164
165     topic = ''
166
167     if status == 'solved':
168         topic += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
169
170     topic += name
171
172     links = []
173     if url:
174         links.append("<{}|Puzzle>".format(url))
175     if sheet_url:
176         links.append("<{}|Sheet>".format(sheet_url))
177
178     if len(links):
179         topic += "({})".format(', '.join(links))
180
181     if state:
182         topic += " {}".format(state)
183
184     # Slack only allows 250 characters for a topic
185     if len(topic) > 250:
186         topic = topic[:247] + "..."
187
188     turb.slack_client.conversations_setTopic(channel=channel_id,
189                                              topic=topic)
190
191     # Rename the sheet to include indication of solved/solution status
192     sheet_name = puzzle['name']
193     if puzzle['status'] == 'solved':
194         sheet_name += " - Solved {}".format(", ".join(puzzle['solution']))
195
196     turbot.sheets.renameSheet(turb, puzzle['sheet_url'], sheet_name)
197
198     # Finally, rename the Slack channel to reflect the latest name and
199     # the solved status
200     #
201     # Note: We don't use puzzle['hunt_id'] here because we're keeping
202     # that as a persistent identifier in the database. Instead we
203     # create a new ID-like identifier from the current name.
204     channel_name = "{}-{}".format(
205         puzzle['hunt_id'],
206         puzzle_id_from_name(puzzle['name'])
207     )
208     if puzzle['status'] == 'solved':
209         channel_name += "-solved"
210
211     turb.slack_client.conversations_rename(
212         channel=puzzle['channel_id'],
213         name=channel_name
214     )