]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Implement a new `/puzzle` slash command to create a puzzle
[turbot] / turbot / interaction.py
1 from slack.errors import SlackApiError
2 from turbot.blocks import input_block, section_block, text_block
3 import turbot.rot
4 import turbot.sheets
5 import turbot.slack
6 import json
7 import re
8 import requests
9
10 TURBOT_USER_ID = 'U01B9QM4P9R'
11
12 actions = {}
13 commands = {}
14 submission_handlers = {}
15
16 # Hunt and Puzzle IDs are restricted to letters, numbers, and underscores
17 valid_id_re = r'^[_a-zA-Z0-9]+$'
18
19 def bot_reply(message):
20     """Construct a return value suitable for a bot reply
21
22     This is suitable as a way to give an error back to the user who
23     initiated a slash command, for example."""
24
25     return {
26         'statusCode': 200,
27         'body': message
28     }
29
30 def submission_error(field, error):
31     """Construct an error suitable for returning for an invalid submission.
32
33     Returning this value will prevent a submission and alert the user that
34     the given field is invalid because of the given error."""
35
36     print("Rejecting invalid modal submission: {}".format(error))
37
38     return {
39         'statusCode': 200,
40         'headers': {
41             "Content-Type": "application/json"
42         },
43         'body': json.dumps({
44             "response_action": "errors",
45             "errors": {
46                 field: error
47             }
48         })
49     }
50
51 def new_hunt(turb, payload):
52     """Handler for the action of user pressing the new_hunt button"""
53
54     view = {
55         "type": "modal",
56         "private_metadata": json.dumps({}),
57         "title": { "type": "plain_text", "text": "New Hunt" },
58         "submit": { "type": "plain_text", "text": "Create" },
59         "blocks": [
60             input_block("Hunt name", "name", "Name of the hunt"),
61             input_block("Hunt ID", "hunt_id",
62                         "Used as puzzle channel prefix "
63                         + "(no spaces nor punctuation)"),
64             input_block("Hunt URL", "url", "External URL of hunt",
65                         optional=True)
66         ],
67     }
68
69     result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
70                                           view=view)
71     if (result['ok']):
72         submission_handlers[result['view']['id']] = new_hunt_submission
73
74     return {
75         'statusCode': 200,
76         'body': 'OK'
77     }
78
79 actions['button'] = {"new_hunt": new_hunt}
80
81 def new_hunt_submission(turb, payload, metadata):
82     """Handler for the user submitting the new hunt modal
83
84     This is the modal view presented to the user by the new_hunt
85     function above."""
86
87     state = payload['view']['state']['values']
88     name = state['name']['name']['value']
89     hunt_id = state['hunt_id']['hunt_id']['value']
90     url = state['url']['url']['value']
91
92     # Validate that the hunt_id contains no invalid characters
93     if not re.match(valid_id_re, hunt_id):
94         return submission_error("hunt_id",
95                                 "Hunt ID can only contain letters, "
96                                 + "numbers, and underscores")
97
98     # Create a channel for the hunt
99     try:
100         response = turb.slack_client.conversations_create(name=hunt_id)
101     except SlackApiError as e:
102         return submission_error("hunt_id",
103                                 "Error creating Slack channel: {}"
104                                 .format(e.response['error']))
105
106     if not response['ok']:
107         return submission_error("name",
108                                 "Error occurred creating Slack channel "
109                                 + "(see CloudWatch log")
110
111     user_id = payload['user']['id']
112     channel_id = response['channel']['id']
113
114     # Create a sheet for the channel
115     sheet = turbot.sheets.sheets_create(turb, hunt_id)
116
117     # Insert the newly-created hunt into the database
118     hunts_table = turb.db.Table("hunts")
119     hunts_table.put_item(
120         Item={
121             'channel_id': channel_id,
122             "active": True,
123             "name": name,
124             "hunt_id": hunt_id,
125             "url": url,
126             "sheet_url": sheet['url']
127         }
128     )
129
130     # Invite the initiating user to the channel
131     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
132
133     # Message the channel with the URL of the sheet
134     turb.slack_client.chat_postMessage(channel=channel_id,
135                                        text="Sheet created for this hunt: {}"
136                                        .format(sheet['url']))
137
138     # Create a database table for this hunt's puzzles
139     table = turb.db.create_table(
140         TableName=hunt_id,
141         AttributeDefinitions=[
142             {'AttributeName': 'channel_id', 'AttributeType': 'S'}
143         ],
144         KeySchema=[
145             {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
146         ],
147         ProvisionedThroughput={
148             'ReadCapacityUnits': 5,
149             'WriteCapacityUnits': 4
150         }
151     )
152
153     # Message the hunt channel that the database is ready
154     turb.slack_client.chat_postMessage(
155         channel=channel_id,
156         text="Welcome to your new hunt! "
157         + "Use `/puzzle` to create puzzles for the hunt.")
158
159     return {
160         'statusCode': 200,
161     }
162
163 def view_submission(turb, payload):
164     """Handler for Slack interactive view submission
165
166     Specifically, those that have a payload type of 'view_submission'"""
167
168     view_id = payload['view']['id']
169     metadata = payload['view']['private_metadata']
170
171     if view_id in submission_handlers:
172         return submission_handlers[view_id](turb, payload, metadata)
173
174     print("Error: Unknown view ID: {}".format(view_id))
175     return {
176         'statusCode': 400
177     }
178
179 def rot(turb, body, args):
180     """Implementation of the /rot command
181
182     The args string should be as follows:
183
184         [count|*] String to be rotated
185
186     That is, the first word of the string is an optional number (or
187     the character '*'). If this is a number it indicates an amount to
188     rotate each character in the string. If the count is '*' or is not
189     present, then the string will be rotated through all possible 25
190     values.
191
192     The result of the rotation is returned (with Slack formatting) in
193     the body of the response so that Slack will provide it as a reply
194     to the user who submitted the slash command."""
195
196     channel_name = body['channel_name'][0]
197     response_url = body['response_url'][0]
198     channel_id = body['channel_id'][0]
199
200     result = turbot.rot.rot(args)
201
202     if (channel_name == "directmessage"):
203         requests.post(response_url,
204                       json = {"text": result},
205                       headers = {"Content-type": "application/json"})
206     else:
207         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
208
209     return {
210         'statusCode': 200,
211         'body': ""
212     }
213
214 commands["/rot"] = rot
215
216 def puzzle(turb, body, args):
217     """Implementation of the /puzzle command
218
219     The args string is currently ignored (this command will bring up
220     a modal dialog for user input instead)."""
221
222     channel_id = body['channel_id'][0]
223     trigger_id = body['trigger_id'][0]
224
225     hunts_table = turb.db.Table("hunts")
226     response = hunts_table.get_item(Key={'channel_id': channel_id})
227
228     if 'Item' in response:
229         hunt_name = response['Item']['name']
230         hunt_id = response['Item']['hunt_id']
231     else:
232         return bot_reply("Sorry, this channel doesn't appear to "
233                          + "be a hunt channel")
234
235     view = {
236         "type": "modal",
237         "private_metadata": json.dumps({
238             "hunt_id": hunt_id,
239             "hunt_channel_id": channel_id
240         }),
241         "title": {"type": "plain_text", "text": "New Puzzle"},
242         "submit": { "type": "plain_text", "text": "Create" },
243         "blocks": [
244             section_block(text_block("*For {}*".format(hunt_name))),
245             input_block("Puzzle name", "name", "Name of the puzzle"),
246             input_block("Puzzle ID", "puzzle_id",
247                         "Used as part of channel name "
248                         + "(no spaces nor punctuation)"),
249             input_block("Puzzle URL", "url", "External URL of puzzle",
250                         optional=True)
251         ]
252     }
253
254     result = turb.slack_client.views_open(trigger_id=trigger_id,
255                                           view=view)
256
257     if (result['ok']):
258         submission_handlers[result['view']['id']] = puzzle_submission
259
260     return {
261         'statusCode': 200
262     }
263
264 commands["/puzzle"] = puzzle
265
266 def puzzle_submission(turb, payload, metadata):
267     """Handler for the user submitting the new puzzle modal
268
269     This is the modal view presented to the user by the puzzle function
270     above."""
271
272     print("In puzzle_submission\npayload is: {}\nmetadata is {}"
273           .format(payload, metadata))
274
275     meta = json.loads(metadata)
276     hunt_id = meta['hunt_id']
277     hunt_channel_id = meta['hunt_channel_id']
278
279     state = payload['view']['state']['values']
280     name = state['name']['name']['value']
281     puzzle_id = state['puzzle_id']['puzzle_id']['value']
282     url = state['url']['url']['value']
283
284     # Validate that the puzzle_id contains no invalid characters
285     if not re.match(valid_id_re, puzzle_id):
286         return submission_error("puzzle_id",
287                                 "Puzzle ID can only contain letters, "
288                                 + "numbers, and underscores")
289
290     # Create a channel for the puzzle
291     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
292
293     try:
294         response = turb.slack_client.conversations_create(
295             name=hunt_dash_channel)
296     except SlackApiError as e:
297         return submission_error("puzzle_id",
298                                 "Error creating Slack channel: {}"
299                                 .format(e.response['error']))
300
301     puzzle_channel_id = response['channel']['id']
302
303     # Create a sheet for the puzzle
304     sheet = turbot.sheets.sheets_create_for_puzzle(turb, hunt_dash_channel)
305
306     # Insert the newly-created puzzle into the database
307     table = turb.db.Table(hunt_id)
308
309     table.put_item(
310         Item={
311             "channel_id": puzzle_channel_id,
312             "solution": [],
313             "status": 'unsolved',
314             "name": name,
315             "puzzle_id": puzzle_id,
316             "url": url,
317             "sheet_url": sheet['url']
318         }
319     )
320
321     # Find all members of the hunt channel
322     members = turbot.slack.slack_channel_members(turb.slack_client,
323                                                  hunt_channel_id)
324
325     # Filter out Turbot's own ID to avoid inviting itself
326     members = [m for m in members if m != TURBOT_USER_ID]
327
328     turb.slack_client.chat_postMessage(channel=puzzle_channel_id,
329                                        text="Inviting members: {}".format(str(members)))
330
331     # Invite those members to the puzzle channel (in chunks of 500)
332     cursor = 0
333     while cursor < len(members):
334         turb.slack_client.conversations_invite(
335             channel=puzzle_channel_id,
336             users=members[cursor:cursor + 500])
337         cursor += 500
338
339     # Message the channel with the URL of the puzzle's sheet
340     turb.slack_client.chat_postMessage(channel=puzzle_channel_id,
341                                        text="Sheet created for this puzzle: {}"
342                                        .format(sheet['url']))
343     return {
344         'statusCode': 200
345     }