]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Add hunt_id to the puzzle table (instead of orig_channel_name)
[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             "solution": [],
360             "status": 'unsolved',
361             "hunt_id": hunt_id,
362             "name": name,
363             "puzzle_id": puzzle_id,
364             "url": url,
365         }
366     )
367
368     return lambda_ok
369
370 # XXX: This duplicates functionality eith events.py:set_channel_description
371 def set_channel_topic(turb, puzzle):
372     channel_id = puzzle['channel_id']
373     name = puzzle['name']
374     url = puzzle.get('url', None)
375     sheet_url = puzzle.get('sheet_url', None)
376     state = puzzle.get('state', None)
377     status = puzzle['status']
378
379     description = ''
380
381     if status == 'solved':
382         description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
383
384     description += name
385
386     links = []
387     if url:
388         links.append("<{}|Puzzle>".format(url))
389     if sheet_url:
390         links.append("<{}|Sheet>".format(sheet_url))
391
392     if len(links):
393         description += "({})".format(', '.join(links))
394
395     if state:
396         description += " {}".format(state)
397
398     turb.slack_client.conversations_setTopic(channel=channel_id,
399                                              topic=description)
400
401 def state(turb, body, args):
402     """Implementation of the /state command
403
404     The args string should be a brief sentence describing where things
405     stand or what's needed."""
406
407     channel_id = body['channel_id'][0]
408     channel_name = body['channel_name'][0]
409
410     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
411
412     if not puzzle:
413         return bot_reply("Sorry, this is not a puzzle channel.")
414
415     # Set the state field in the database
416     puzzle['state'] = args
417     table.put_item(Item=puzzle)
418
419     set_channel_topic(turb, puzzle)
420
421     return lambda_ok
422
423 commands["/state"] = state
424
425 def solved(turb, body, args):
426     """Implementation of the /solved command
427
428     The args string should be a confirmed solution."""
429
430     channel_id = body['channel_id'][0]
431     channel_name = body['channel_name'][0]
432     user_name = body['user_name'][0]
433
434     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
435
436     if not puzzle:
437         return bot_reply("Sorry, this is not a puzzle channel.")
438
439     # Set the status and solution fields in the database
440     puzzle['status'] = 'solved'
441     puzzle['solution'].append(args)
442     table.put_item(Item=puzzle)
443
444     # Report the solution to the puzzle's channel
445     slack_send_message(
446         turb.slack_client, channel_id,
447         "Puzzle mark solved by {}: `{}`".format(user_name, args))
448
449     # Also report the solution to the hunt channel
450     (hunt, _) = get_table_item(turb, "hunts", "hunt_id", puzzle['hunt_id'])
451     slack_send_message(
452         turb.slack_client, hunt['channel_id'],
453         "Puzzle '<{}|{}>' has been solved!".format(
454             puzzle['channel_url'],
455             puzzle['name'])
456     )
457
458     # And update the puzzle's description
459     set_channel_topic(turb, puzzle)
460
461     # And rename the sheet to prefix with "SOLVED: "
462     turbot.sheets.renameSheet(turb, puzzle['sheet_url'], 'SOLVED: ' + puzzle['name'])
463
464     # Finally, rename the Slack channel to add the suffix '-solved'
465     channel_name = "{}-{}-solved".format(
466         puzzle['hunt_id'],
467         puzzle['puzzle_id'])
468     turb.slack_client.conversations_rename(
469         channel=puzzle['channel_id'],
470         name=channel_name)
471
472     return lambda_ok
473
474 commands["/solved"] = solved