]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Make the /solved command report to the hunt's channel
[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 None if channel does not belong to a hunt, otherwise a
254     dictionary with all fields from the hunt's row in the table,
255     (channel_id, active_, hun_id, name, url, sheet_url, etc.).
256
257     """
258
259     (hunt, _) = channel_is_hunt(turb, channel_id)
260
261     if hunt:
262         return hunt
263
264     # So we're not a hunt channel, let's look to see if we are a
265     # puzzle channel with a hunt-id prefix.
266     hunt_id = channel_name.split('-')[0]
267
268     hunts_table = turb.db.Table("hunts")
269
270     response = hunts_table.scan(
271         FilterExpression='hunt_id = :hunt_id',
272         ExpressionAttributeValues={':hunt_id': hunt_id}
273     )
274
275     if 'Items' in response and len(response['Items']):
276         item = response['Items'][0]
277         return item
278
279     return None
280
281 def puzzle(turb, body, args):
282     """Implementation of the /puzzle command
283
284     The args string is currently ignored (this command will bring up
285     a modal dialog for user input instead)."""
286
287     channel_id = body['channel_id'][0]
288     channel_name = body['channel_name'][0]
289     trigger_id = body['trigger_id'][0]
290
291     hunt = find_hunt_for_channel(turb,
292                                  channel_id,
293                                  channel_name)
294
295     if not hunt:
296         return bot_reply("Sorry, this channel doesn't appear to "
297                          + "be a hunt or puzzle channel")
298
299     view = {
300         "type": "modal",
301         "private_metadata": json.dumps({
302             "hunt_id": hunt['hunt_id'],
303         }),
304         "title": {"type": "plain_text", "text": "New Puzzle"},
305         "submit": { "type": "plain_text", "text": "Create" },
306         "blocks": [
307             section_block(text_block("*For {}*".format(hunt['name']))),
308             input_block("Puzzle name", "name", "Name of the puzzle"),
309             input_block("Puzzle URL", "url", "External URL of puzzle",
310                         optional=True)
311         ]
312     }
313
314     result = turb.slack_client.views_open(trigger_id=trigger_id,
315                                           view=view)
316
317     if (result['ok']):
318         submission_handlers[result['view']['id']] = puzzle_submission
319
320     return lambda_ok
321
322 commands["/puzzle"] = puzzle
323
324 def puzzle_submission(turb, payload, metadata):
325     """Handler for the user submitting the new puzzle modal
326
327     This is the modal view presented to the user by the puzzle function
328     above."""
329
330     meta = json.loads(metadata)
331     hunt_id = meta['hunt_id']
332
333     state = payload['view']['state']['values']
334     name = state['name']['name']['value']
335     url = state['url']['url']['value']
336
337     # Create a Slack-channel-safe puzzle_id
338     puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
339
340     # Create a channel for the puzzle
341     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
342
343     try:
344         response = turb.slack_client.conversations_create(
345             name=hunt_dash_channel)
346     except SlackApiError as e:
347         return submission_error(
348             "name",
349             "Error creating Slack channel {}: {}"
350             .format(hunt_dash_channel, e.response['error']))
351
352     puzzle_channel_id = response['channel']['id']
353
354     # Insert the newly-created puzzle into the database
355     table = turb.db.Table(hunt_id)
356     table.put_item(
357         Item={
358             "channel_id": puzzle_channel_id,
359             "orig_channel_name": hunt_dash_channel,
360             "solution": [],
361             "status": 'unsolved',
362             "name": name,
363             "puzzle_id": puzzle_id,
364             "url": url,
365         }
366     )
367
368     return lambda_ok
369
370 def rename_channel_to_solved(turb, puzzle):
371     orig_channel_name = puzzle['orig_channel_name']
372     channel_id = puzzle['channel_id']
373     newName = orig_channel_name + '-solved'
374     turb.slack_client.conversations_rename(channel=channel_id,
375                                            name=newName)
376
377 # XXX: This duplicates functionality eith events.py:set_channel_description
378 def set_channel_topic(turb, puzzle):
379     channel_id = puzzle['channel_id']
380     name = puzzle['name']
381     url = puzzle.get('url', None)
382     sheet_url = puzzle.get('sheet_url', None)
383     state = puzzle.get('state', None)
384     status = puzzle['status']
385
386     description = ''
387
388     if status == 'solved':
389         description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
390
391     description += name
392
393     links = []
394     if url:
395         links.append("<{}|Puzzle>".format(url))
396     if sheet_url:
397         links.append("<{}|Sheet>".format(sheet_url))
398
399     if len(links):
400         description += "({})".format(', '.join(links))
401
402     if state:
403         description += " {}".format(state)
404
405     turb.slack_client.conversations_setTopic(channel=channel_id,
406                                              topic=description)
407
408 def state(turb, body, args):
409     """Implementation of the /state command
410
411     The args string should be a brief sentence describing where things
412     stand or what's needed."""
413
414     channel_id = body['channel_id'][0]
415     channel_name = body['channel_name'][0]
416
417     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
418
419     if not puzzle:
420         return bot_reply("Sorry, this is not a puzzle channel.")
421
422     # Set the state field in the database
423     puzzle['state'] = args
424     table.put_item(Item=puzzle)
425
426     set_channel_topic(turb, puzzle)
427
428     return lambda_ok
429
430 commands["/state"] = state
431
432 def solved(turb, body, args):
433     """Implementation of the /solved command
434
435     The args string should be a confirmed solution."""
436
437     channel_id = body['channel_id'][0]
438     channel_name = body['channel_name'][0]
439     user_name = body['user_name'][0]
440
441     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
442
443     if not puzzle:
444         return bot_reply("Sorry, this is not a puzzle channel.")
445
446     # Set the status and solution fields in the database
447     puzzle['status'] = 'solved'
448     puzzle['solution'].append(args)
449     table.put_item(Item=puzzle)
450
451     # Report the solution to the puzzle's channel
452     slack_send_message(
453         turb.slack_client, channel_id,
454         "Puzzle mark solved by {}: `{}`".format(user_name, args))
455
456     # Also report the solution to the hunt channel
457     (hunt, _) = get_table_item(turb, "hunts", "hunt_id", puzzle['hunt_id'])
458     slack_send_message(
459         turb.slack_client, hunt['channel_id'],
460         "Puzzle '<{}|{}>' has been solved!".format(
461             puzzle['channel_url'],
462             puzzle['name'])
463     )
464
465     # And update the puzzle's description
466     set_channel_topic(turb, puzzle)
467
468     # And rename the sheet to prefix with "SOLVED: "
469     turbot.sheets.renameSheet(turb, puzzle['sheet_url'], 'SOLVED: ' + puzzle['name'])
470
471     # Finally, rename the Slack channel to add the suffix '-solved'
472     rename_channel_to_solved(turb, puzzle)
473
474     return lambda_ok
475
476 commands["/solved"] = solved