]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Drop the puzzle ID field from the /puzzle dialog
[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 from botocore.exceptions import ClientError
10 from turbot.slack import slack_send_message
11
12 actions = {}
13 commands = {}
14 submission_handlers = {}
15
16 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
17 valid_id_re = r'^[_a-z0-9]+$'
18
19 lambda_ok = {'statusCode': 200}
20
21 def bot_reply(message):
22     """Construct a return value suitable for a bot reply
23
24     This is suitable as a way to give an error back to the user who
25     initiated a slash command, for example."""
26
27     return {
28         'statusCode': 200,
29         'body': message
30     }
31
32 def submission_error(field, error):
33     """Construct an error suitable for returning for an invalid submission.
34
35     Returning this value will prevent a submission and alert the user that
36     the given field is invalid because of the given error."""
37
38     print("Rejecting invalid modal submission: {}".format(error))
39
40     return {
41         'statusCode': 200,
42         'headers': {
43             "Content-Type": "application/json"
44         },
45         'body': json.dumps({
46             "response_action": "errors",
47             "errors": {
48                 field: error
49             }
50         })
51     }
52
53 def new_hunt(turb, payload):
54     """Handler for the action of user pressing the new_hunt button"""
55
56     view = {
57         "type": "modal",
58         "private_metadata": json.dumps({}),
59         "title": { "type": "plain_text", "text": "New Hunt" },
60         "submit": { "type": "plain_text", "text": "Create" },
61         "blocks": [
62             input_block("Hunt name", "name", "Name of the hunt"),
63             input_block("Hunt ID", "hunt_id",
64                         "Used as puzzle channel prefix "
65                         + "(no spaces nor punctuation)"),
66             input_block("Hunt URL", "url", "External URL of hunt",
67                         optional=True)
68         ],
69     }
70
71     result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
72                                           view=view)
73     if (result['ok']):
74         submission_handlers[result['view']['id']] = new_hunt_submission
75
76     return lambda_ok
77
78 actions['button'] = {"new_hunt": new_hunt}
79
80 def new_hunt_submission(turb, payload, metadata):
81     """Handler for the user submitting the new hunt modal
82
83     This is the modal view presented to the user by the new_hunt
84     function above."""
85
86     state = payload['view']['state']['values']
87     user_id = payload['user']['id']
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 lowercase letters, "
96                                 + "numbers, and underscores")
97
98     # Check to see if the hunts table exists
99     hunts_table = turb.db.Table("hunts")
100
101     try:
102         exists = hunts_table.table_status in ("CREATING", "UPDATING",
103                                               "ACTIVE")
104     except ClientError:
105         exists = False
106
107     # Create the hunts table if necessary.
108     if not exists:
109         hunts_table = turb.db.create_table(
110             TableName='hunts',
111             KeySchema=[
112                 {'AttributeName': 'channel_id', 'KeyType': 'HASH'},
113             ],
114             AttributeDefinitions=[
115                 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
116             ],
117             ProvisionedThroughput={
118                 'ReadCapacityUnits': 5,
119                 'WriteCapacityUnits': 5
120             }
121         )
122         return submission_error("hunt_id",
123                                 "Still bootstrapping hunts table. Try again.")
124
125     # Create a channel for the hunt
126     try:
127         response = turb.slack_client.conversations_create(name=hunt_id)
128     except SlackApiError as e:
129         return submission_error("hunt_id",
130                                 "Error creating Slack channel: {}"
131                                 .format(e.response['error']))
132
133     channel_id = response['channel']['id']
134
135     # Insert the newly-created hunt into the database
136     # (leaving it as non-active for now until the channel-created handler
137     #  finishes fixing it up with a sheet and a companion table)
138     hunts_table.put_item(
139         Item={
140             'channel_id': channel_id,
141             "active": False,
142             "name": name,
143             "hunt_id": hunt_id,
144             "url": url
145         }
146     )
147
148     # Invite the initiating user to the channel
149     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
150
151     return lambda_ok
152
153 def view_submission(turb, payload):
154     """Handler for Slack interactive view submission
155
156     Specifically, those that have a payload type of 'view_submission'"""
157
158     view_id = payload['view']['id']
159     metadata = payload['view']['private_metadata']
160
161     if view_id in submission_handlers:
162         return submission_handlers[view_id](turb, payload, metadata)
163
164     print("Error: Unknown view ID: {}".format(view_id))
165     return {
166         'statusCode': 400
167     }
168
169 def rot(turb, body, args):
170     """Implementation of the /rot command
171
172     The args string should be as follows:
173
174         [count|*] String to be rotated
175
176     That is, the first word of the string is an optional number (or
177     the character '*'). If this is a number it indicates an amount to
178     rotate each character in the string. If the count is '*' or is not
179     present, then the string will be rotated through all possible 25
180     values.
181
182     The result of the rotation is returned (with Slack formatting) in
183     the body of the response so that Slack will provide it as a reply
184     to the user who submitted the slash command."""
185
186     channel_name = body['channel_name'][0]
187     response_url = body['response_url'][0]
188     channel_id = body['channel_id'][0]
189
190     result = turbot.rot.rot(args)
191
192     if (channel_name == "directmessage"):
193         requests.post(response_url,
194                       json = {"text": result},
195                       headers = {"Content-type": "application/json"})
196     else:
197         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
198
199     return lambda_ok
200
201 commands["/rot"] = rot
202
203 def get_table_item(turb, table_name, key, value):
204     """Get an item from the database 'table_name' with 'key' as 'value'
205
206     Returns a tuple of (item, table) if found and (None, None) otherwise."""
207
208     table = turb.db.Table(table_name)
209
210     response = table.get_item(Key={key: value})
211
212     if 'Item' in response:
213         return (response['Item'], table)
214     else:
215         return (None, None)
216
217 def channel_is_puzzle(turb, channel_id, channel_name):
218     """Given a channel ID/name return the database item for the puzzle
219
220     If this channel is a puzzle, this function returns a tuple:
221
222         (puzzle, table)
223
224     Where puzzle is dict filled with database entries, and table is a
225     database table that can be used to update the puzzle in the
226     database.
227
228     Otherwise, this function returns (None, None)."""
229
230     hunt_id = channel_name.split('-')[0]
231
232     # Not a puzzle channel if there is no hyphen in the name
233     if hunt_id == channel_name:
234         return (None, None)
235
236     return get_table_item(turb, hunt_id, 'channel_id', channel_id)
237
238 def channel_is_hunt(turb, channel_id):
239
240     """Given a channel ID/name return the database item for the hunt
241
242     Returns a dict (filled with database entries) if there is a hunt
243     for this channel, otherwise returns None."""
244
245     return get_table_item(turb, "hunts", 'channel_id', channel_id)
246
247 def find_hunt_for_channel(turb, channel_id, channel_name):
248     """Given a channel ID/name find the id/name of the hunt for this channel
249
250     This works whether the original channel is a primary hunt channel,
251     or if it is one of the channels of a puzzle belonging to the hunt.
252
253     Returns a tuple of (hunt_name, hunt_id) or (None, None)."""
254
255     (hunt, _) = channel_is_hunt(turb, channel_id)
256
257     if hunt:
258         return (hunt['hunt_id'], hunt['name'])
259
260     # So we're not a hunt channel, let's look to see if we are a
261     # puzzle channel with a hunt-id prefix.
262     hunt_id = channel_name.split('-')[0]
263
264     hunts_table = turb.db.Table("hunts")
265
266     response = hunts_table.scan(
267         FilterExpression='hunt_id = :hunt_id',
268         ExpressionAttributeValues={':hunt_id': hunt_id}
269     )
270
271     if 'Items' in response and len(response['Items']):
272         item = response['Items'][0]
273         return (item['hunt_id'], item['name'])
274
275     return (None, None)
276
277 def puzzle(turb, body, args):
278     """Implementation of the /puzzle command
279
280     The args string is currently ignored (this command will bring up
281     a modal dialog for user input instead)."""
282
283     channel_id = body['channel_id'][0]
284     channel_name = body['channel_name'][0]
285     trigger_id = body['trigger_id'][0]
286
287     (hunt_id, hunt_name) = find_hunt_for_channel(turb,
288                                                  channel_id,
289                                                  channel_name)
290
291     if not hunt_id:
292         return bot_reply("Sorry, this channel doesn't appear to "
293                          + "be a hunt or puzzle channel")
294
295     view = {
296         "type": "modal",
297         "private_metadata": json.dumps({
298             "hunt_id": hunt_id,
299         }),
300         "title": {"type": "plain_text", "text": "New Puzzle"},
301         "submit": { "type": "plain_text", "text": "Create" },
302         "blocks": [
303             section_block(text_block("*For {}*".format(hunt_name))),
304             input_block("Puzzle name", "name", "Name of the puzzle"),
305             input_block("Puzzle URL", "url", "External URL of puzzle",
306                         optional=True)
307         ]
308     }
309
310     result = turb.slack_client.views_open(trigger_id=trigger_id,
311                                           view=view)
312
313     if (result['ok']):
314         submission_handlers[result['view']['id']] = puzzle_submission
315
316     return lambda_ok
317
318 commands["/puzzle"] = puzzle
319
320 def puzzle_submission(turb, payload, metadata):
321     """Handler for the user submitting the new puzzle modal
322
323     This is the modal view presented to the user by the puzzle function
324     above."""
325
326     meta = json.loads(metadata)
327     hunt_id = meta['hunt_id']
328
329     state = payload['view']['state']['values']
330     name = state['name']['name']['value']
331     url = state['url']['url']['value']
332
333     # Create a Slack-channel-safe puzzle_id
334     puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
335
336     # Create a channel for the puzzle
337     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
338
339     try:
340         response = turb.slack_client.conversations_create(
341             name=hunt_dash_channel)
342     except SlackApiError as e:
343         return submission_error(
344             "name",
345             "Error creating Slack channel {}: {}"
346             .format(hunt_dash_channel, e.response['error']))
347
348     puzzle_channel_id = response['channel']['id']
349
350     # Insert the newly-created puzzle into the database
351     table = turb.db.Table(hunt_id)
352     table.put_item(
353         Item={
354             "channel_id": puzzle_channel_id,
355             "solution": [],
356             "status": 'unsolved',
357             "name": name,
358             "puzzle_id": puzzle_id,
359             "url": url,
360         }
361     )
362
363     return lambda_ok
364
365 # XXX: This duplicates functionality eith events.py:set_channel_description
366 def set_channel_topic(turb, puzzle):
367     channel_id = puzzle['channel_id']
368     name = puzzle['name']
369     url = puzzle.get('url', None)
370     sheet_url = puzzle.get('sheet_url', None)
371     state = puzzle.get('state', None)
372     status = puzzle['status']
373
374     description = ''
375
376     if status == 'solved':
377         description += "Solved: `{}` ".format('`, `'.join(puzzle['solution']))
378
379     description += name
380
381     links = []
382     if url:
383         links.append("<{}|Puzzle>".format(url))
384     if sheet_url:
385         links.append("<{}|Sheet>".format(sheet_url))
386
387     if len(links):
388         description += "({})".format(', '.join(links))
389
390     if state:
391         description += " {}".format(state)
392
393     turb.slack_client.conversations_setTopic(channel=channel_id,
394                                              topic=description)
395
396 def state(turb, body, args):
397     """Implementation of the /state command
398
399     The args string should be a brief sentence describing where things
400     stand or what's needed."""
401
402     channel_id = body['channel_id'][0]
403     channel_name = body['channel_name'][0]
404
405     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
406
407     if not puzzle:
408         return bot_reply("Sorry, this is not a puzzle channel.")
409
410     # Set the state field in the database
411     puzzle['state'] = args
412     table.put_item(Item=puzzle)
413
414     set_channel_topic(turb, puzzle)
415
416     return lambda_ok
417
418 commands["/state"] = state
419
420 def solved(turb, body, args):
421     """Implementation of the /solved command
422
423     The args string should be a confirmed solution."""
424
425     channel_id = body['channel_id'][0]
426     channel_name = body['channel_name'][0]
427     user_name = body['user_name'][0]
428
429     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
430
431     if not puzzle:
432         return bot_reply("Sorry, this is not a puzzle channel.")
433
434     # Set the status and solution fields in the database
435     puzzle['status'] = 'solved'
436     puzzle['solution'].append(args)
437     table.put_item(Item=puzzle)
438
439     # Report the solution to the puzzle's channel
440     slack_send_message(
441         turb.slack_client, channel_id,
442         "Puzzle mark solved by {}: `{}`".format(user_name, args))
443
444     # And update the puzzle's description
445     set_channel_topic(turb, puzzle)
446
447     return lambda_ok
448
449 commands["/solved"] = solved