]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Add a multi-select round field to puzzle creation
[turbot] / turbot / interaction.py
1 from slack.errors import SlackApiError
2 from turbot.blocks import (
3     input_block, section_block, text_block, multi_select_block
4 )
5 from turbot.hunt import find_hunt_for_hunt_id
6 import turbot.rot
7 import turbot.sheets
8 import turbot.slack
9 import json
10 import re
11 import requests
12 from botocore.exceptions import ClientError
13 from boto3.dynamodb.conditions import Key
14 from turbot.slack import slack_send_message
15
16 actions = {}
17 commands = {}
18 submission_handlers = {}
19
20 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
21 valid_id_re = r'^[_a-z0-9]+$'
22
23 lambda_ok = {'statusCode': 200}
24
25 def bot_reply(message):
26     """Construct a return value suitable for a bot reply
27
28     This is suitable as a way to give an error back to the user who
29     initiated a slash command, for example."""
30
31     return {
32         'statusCode': 200,
33         'body': message
34     }
35
36 def submission_error(field, error):
37     """Construct an error suitable for returning for an invalid submission.
38
39     Returning this value will prevent a submission and alert the user that
40     the given field is invalid because of the given error."""
41
42     print("Rejecting invalid modal submission: {}".format(error))
43
44     return {
45         'statusCode': 200,
46         'headers': {
47             "Content-Type": "application/json"
48         },
49         'body': json.dumps({
50             "response_action": "errors",
51             "errors": {
52                 field: error
53             }
54         })
55     }
56
57 def multi_static_select(turb, payload):
58     """Handler for the action of user entering a multi-select value"""
59
60     return lambda_ok
61
62 actions['multi_static_select'] = {"*": multi_static_select}
63
64 def new_hunt(turb, payload):
65     """Handler for the action of user pressing the new_hunt button"""
66
67     view = {
68         "type": "modal",
69         "private_metadata": json.dumps({}),
70         "title": { "type": "plain_text", "text": "New Hunt" },
71         "submit": { "type": "plain_text", "text": "Create" },
72         "blocks": [
73             input_block("Hunt name", "name", "Name of the hunt"),
74             input_block("Hunt ID", "hunt_id",
75                         "Used as puzzle channel prefix "
76                         + "(no spaces nor punctuation)"),
77             input_block("Hunt URL", "url", "External URL of hunt",
78                         optional=True)
79         ],
80     }
81
82     result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
83                                           view=view)
84     if (result['ok']):
85         submission_handlers[result['view']['id']] = new_hunt_submission
86
87     return lambda_ok
88
89 actions['button'] = {"new_hunt": new_hunt}
90
91 def new_hunt_submission(turb, payload, metadata):
92     """Handler for the user submitting the new hunt modal
93
94     This is the modal view presented to the user by the new_hunt
95     function above."""
96
97     state = payload['view']['state']['values']
98     user_id = payload['user']['id']
99     name = state['name']['name']['value']
100     hunt_id = state['hunt_id']['hunt_id']['value']
101     url = state['url']['url']['value']
102
103     # Validate that the hunt_id contains no invalid characters
104     if not re.match(valid_id_re, hunt_id):
105         return submission_error("hunt_id",
106                                 "Hunt ID can only contain lowercase letters, "
107                                 + "numbers, and underscores")
108
109     # Check to see if the turbot table exists
110     try:
111         exists = turb.table.table_status in ("CREATING", "UPDATING",
112                                              "ACTIVE")
113     except ClientError:
114         exists = False
115
116     # Create the turbot table if necessary.
117     if not exists:
118         turb.table = turb.db.create_table(
119             TableName='turbot',
120             KeySchema=[
121                 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
122                 {'AttributeName': 'SK', 'KeyType': 'RANGE'},
123             ],
124             AttributeDefinitions=[
125                 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
126                 {'AttributeName': 'SK', 'AttributeType': 'S'},
127                 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
128                 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
129             ],
130             ProvisionedThroughput={
131                 'ReadCapacityUnits': 5,
132                 'WriteCapacityUnits': 5
133             },
134             GlobalSecondaryIndexes=[
135                 {
136                     'IndexName': 'channel_id_index',
137                     'KeySchema': [
138                         {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
139                     ],
140                     'Projection': {
141                         'ProjectionType': 'ALL'
142                     },
143                     'ProvisionedThroughput': {
144                         'ReadCapacityUnits': 5,
145                         'WriteCapacityUnits': 5
146                     }
147                 },
148                 {
149                     'IndexName': 'is_hunt_index',
150                     'KeySchema': [
151                         {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
152                     ],
153                     'Projection': {
154                         'ProjectionType': 'ALL'
155                     },
156                     'ProvisionedThroughput': {
157                         'ReadCapacityUnits': 5,
158                         'WriteCapacityUnits': 5
159                     }
160                 }
161             ]
162         )
163         return submission_error(
164             "hunt_id",
165             "Still bootstrapping turbot table. Try again in a minute, please.")
166
167     # Create a channel for the hunt
168     try:
169         response = turb.slack_client.conversations_create(name=hunt_id)
170     except SlackApiError as e:
171         return submission_error("hunt_id",
172                                 "Error creating Slack channel: {}"
173                                 .format(e.response['error']))
174
175     channel_id = response['channel']['id']
176
177     # Insert the newly-created hunt into the database
178     # (leaving it as non-active for now until the channel-created handler
179     #  finishes fixing it up with a sheet and a companion table)
180     item={
181         "hunt_id": hunt_id,
182         "SK": "hunt-{}".format(hunt_id),
183         "is_hunt": hunt_id,
184         "channel_id": channel_id,
185         "active": False,
186         "name": name,
187     }
188     if url:
189         item['url'] = url
190     turb.table.put_item(Item=item)
191
192     # Invite the initiating user to the channel
193     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
194
195     return lambda_ok
196
197 def view_submission(turb, payload):
198     """Handler for Slack interactive view submission
199
200     Specifically, those that have a payload type of 'view_submission'"""
201
202     view_id = payload['view']['id']
203     metadata = payload['view']['private_metadata']
204
205     if view_id in submission_handlers:
206         return submission_handlers[view_id](turb, payload, metadata)
207
208     print("Error: Unknown view ID: {}".format(view_id))
209     return {
210         'statusCode': 400
211     }
212
213 def rot(turb, body, args):
214     """Implementation of the /rot command
215
216     The args string should be as follows:
217
218         [count|*] String to be rotated
219
220     That is, the first word of the string is an optional number (or
221     the character '*'). If this is a number it indicates an amount to
222     rotate each character in the string. If the count is '*' or is not
223     present, then the string will be rotated through all possible 25
224     values.
225
226     The result of the rotation is returned (with Slack formatting) in
227     the body of the response so that Slack will provide it as a reply
228     to the user who submitted the slash command."""
229
230     channel_name = body['channel_name'][0]
231     response_url = body['response_url'][0]
232     channel_id = body['channel_id'][0]
233
234     result = turbot.rot.rot(args)
235
236     if (channel_name == "directmessage"):
237         requests.post(response_url,
238                       json = {"text": result},
239                       headers = {"Content-type": "application/json"})
240     else:
241         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
242
243     return lambda_ok
244
245 commands["/rot"] = rot
246
247 def get_table_item(turb, table_name, key, value):
248     """Get an item from the database 'table_name' with 'key' as 'value'
249
250     Returns a tuple of (item, table) if found and (None, None) otherwise."""
251
252     table = turb.db.Table(table_name)
253
254     response = table.get_item(Key={key: value})
255
256     if 'Item' in response:
257         return (response['Item'], table)
258     else:
259         return (None, None)
260
261 def db_entry_for_channel(turb, channel_id):
262     """Given a channel ID return the database item for this channel
263
264     If this channel is a registered hunt or puzzle channel, return the
265     corresponding row from the database for this channel. Otherwise,
266     return None.
267
268     Note: If you need to specifically ensure that the channel is a
269     puzzle or a hunt, please call puzzle_for_channel or
270     hunt_for_channel respectively.
271     """
272
273     response = turb.table.query(
274         IndexName = "channel_id_index",
275         KeyConditionExpression=Key("channel_id").eq(channel_id)
276     )
277
278     if response['Count'] == 0:
279         return None
280
281     return response['Items'][0]
282
283
284 def puzzle_for_channel(turb, channel_id):
285
286     """Given a channel ID return the puzzle from the database for this channel
287
288     If the given channel_id is a puzzle's channel, this function
289     returns a dict filled with the attributes from the puzzle's entry
290     in the database.
291
292     Otherwise, this function returns None.
293     """
294
295     entry = db_entry_for_channel(turb, channel_id)
296
297     if entry and entry['SK'].startswith('puzzle-'):
298         return entry
299     else:
300         return None
301
302 def hunt_for_channel(turb, channel_id):
303
304     """Given a channel ID return the hunt from the database for this channel
305
306     This works whether the original channel is a primary hunt channel,
307     or if it is one of the channels of a puzzle belonging to the hunt.
308
309     Returns None if channel does not belong to a hunt, otherwise a
310     dictionary with all fields from the hunt's row in the table,
311     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
312     """
313
314     entry = db_entry_for_channel(turb, channel_id)
315
316     # We're done if this channel doesn't exist in the database at all
317     if not entry:
318         return None
319
320     # Also done if this channel is a hunt channel
321     if entry['SK'].startswith('hunt-'):
322         return entry
323
324     # Otherwise, (the channel is in the database, but is not a hunt),
325     # we expect this to be a puzzle channel instead
326     return find_hunt_for_hunt_id(turb, entry['hunt_id'])
327
328 # python3.9 has a built-in removeprefix but AWS only has python3.8
329 def remove_prefix(text, prefix):
330     if text.startswith(prefix):
331         return text[len(prefix):]
332     return text
333
334 def hunt_rounds(turb, hunt_id):
335     """Returns array of strings giving rounds that exist in the given hunt"""
336
337     response = turb.table.query(
338         KeyConditionExpression=(
339             Key('hunt_id').eq(hunt_id) &
340             Key('SK').begins_with('round-')
341         )
342     )
343
344     if response['Count'] == 0:
345         return []
346
347     return [remove_prefix(option['SK'], 'round-')
348             for option in response['Items']]
349
350 def puzzle(turb, body, args):
351     """Implementation of the /puzzle command
352
353     The args string is currently ignored (this command will bring up
354     a modal dialog for user input instead)."""
355
356     channel_id = body['channel_id'][0]
357     trigger_id = body['trigger_id'][0]
358
359     hunt = hunt_for_channel(turb, channel_id)
360
361     if not hunt:
362         return bot_reply("Sorry, this channel doesn't appear to "
363                          + "be a hunt or puzzle channel")
364
365     round_options = hunt_rounds(turb, hunt['hunt_id'])
366
367     if len(round_options):
368         round_options_block = [
369             multi_select_block("Round(s)", "rounds",
370                                "Existing round(s) this puzzle belongs to",
371                                round_options)
372         ]
373     else:
374         round_options_block = []
375
376     view = {
377         "type": "modal",
378         "private_metadata": json.dumps({
379             "hunt_id": hunt['hunt_id'],
380         }),
381         "title": {"type": "plain_text", "text": "New Puzzle"},
382         "submit": { "type": "plain_text", "text": "Create" },
383         "blocks": [
384             section_block(text_block("*For {}*".format(hunt['name']))),
385             input_block("Puzzle name", "name", "Name of the puzzle"),
386             input_block("Puzzle URL", "url", "External URL of puzzle",
387                         optional=True),
388             * round_options_block,
389             input_block("New round(s)", "new_rounds",
390                         "New round(s) this puzzle belongs to " +
391                         "(comma separated)",
392                         optional=True)
393         ]
394     }
395
396     result = turb.slack_client.views_open(trigger_id=trigger_id,
397                                           view=view)
398
399     if (result['ok']):
400         submission_handlers[result['view']['id']] = puzzle_submission
401
402     return lambda_ok
403
404 commands["/puzzle"] = puzzle
405
406 def puzzle_submission(turb, payload, metadata):
407     """Handler for the user submitting the new puzzle modal
408
409     This is the modal view presented to the user by the puzzle function
410     above."""
411
412     meta = json.loads(metadata)
413     hunt_id = meta['hunt_id']
414
415     state = payload['view']['state']['values']
416     name = state['name']['name']['value']
417     url = state['url']['url']['value']
418     if 'rounds' in state:
419         rounds = [option['value'] for option in
420                   state['rounds']['rounds']['selected_options']]
421     else:
422         rounds = []
423     new_rounds = state['new_rounds']['new_rounds']['value']
424
425     # Create a Slack-channel-safe puzzle_id
426     puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
427
428     # Create a channel for the puzzle
429     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
430
431     try:
432         response = turb.slack_client.conversations_create(
433             name=hunt_dash_channel)
434     except SlackApiError as e:
435         return submission_error(
436             "name",
437             "Error creating Slack channel {}: {}"
438             .format(hunt_dash_channel, e.response['error']))
439
440     channel_id = response['channel']['id']
441
442     # Add any new rounds to the database
443     if new_rounds:
444         for round in new_rounds.split(','):
445             rounds += round
446             turb.table.put_item(
447                 Item={
448                     'hunt_id': hunt_id,
449                     'SK': 'round-' + round
450                 }
451             )
452
453     # Insert the newly-created puzzle into the database
454     item={
455         "hunt_id": hunt_id,
456         "SK": "puzzle-{}".format(puzzle_id),
457         "puzzle_id": puzzle_id,
458         "channel_id": channel_id,
459         "solution": [],
460         "status": 'unsolved',
461         "name": name,
462     }
463     if url:
464         item['url'] = url
465     if rounds:
466         item['rounds'] = rounds
467     turb.table.put_item(Item=item)
468
469     return lambda_ok
470
471 # XXX: This duplicates functionality eith events.py:set_channel_description
472 def set_channel_topic(turb, puzzle):
473     channel_id = puzzle['channel_id']
474     name = puzzle['name']
475     url = puzzle.get('url', None)
476     sheet_url = puzzle.get('sheet_url', None)
477     state = puzzle.get('state', None)
478     status = puzzle['status']
479
480     description = ''
481
482     if status == 'solved':
483         description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
484
485     description += name
486
487     links = []
488     if url:
489         links.append("<{}|Puzzle>".format(url))
490     if sheet_url:
491         links.append("<{}|Sheet>".format(sheet_url))
492
493     if len(links):
494         description += "({})".format(', '.join(links))
495
496     if state:
497         description += " {}".format(state)
498
499     turb.slack_client.conversations_setTopic(channel=channel_id,
500                                              topic=description)
501
502 def state(turb, body, args):
503     """Implementation of the /state command
504
505     The args string should be a brief sentence describing where things
506     stand or what's needed."""
507
508     channel_id = body['channel_id'][0]
509
510     puzzle = puzzle_for_channel(turb, channel_id)
511
512     if not puzzle:
513         return bot_reply(
514             "Sorry, the /state command only works in a puzzle channel")
515
516     # Set the state field in the database
517     puzzle['state'] = args
518     turb.table.put_item(Item=puzzle)
519
520     set_channel_topic(turb, puzzle)
521
522     return lambda_ok
523
524 commands["/state"] = state
525
526 def solved(turb, body, args):
527     """Implementation of the /solved command
528
529     The args string should be a confirmed solution."""
530
531     channel_id = body['channel_id'][0]
532     user_name = body['user_name'][0]
533
534     puzzle = puzzle_for_channel(turb, channel_id)
535
536     if not puzzle:
537         return bot_reply("Sorry, this is not a puzzle channel.")
538
539     # Set the status and solution fields in the database
540     puzzle['status'] = 'solved'
541     puzzle['solution'].append(args)
542     turb.table.put_item(Item=puzzle)
543
544     # Report the solution to the puzzle's channel
545     slack_send_message(
546         turb.slack_client, channel_id,
547         "Puzzle mark solved by {}: `{}`".format(user_name, args))
548
549     # Also report the solution to the hunt channel
550     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
551     slack_send_message(
552         turb.slack_client, hunt['channel_id'],
553         "Puzzle <{}|{}> has been solved!".format(
554             puzzle['channel_url'],
555             puzzle['name'])
556     )
557
558     # And update the puzzle's description
559     set_channel_topic(turb, puzzle)
560
561     # And rename the sheet to prefix with "SOLVED: "
562     turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
563                               'SOLVED: ' + puzzle['name'])
564
565     # Finally, rename the Slack channel to add the suffix '-solved'
566     channel_name = "{}-{}-solved".format(
567         puzzle['hunt_id'],
568         puzzle['puzzle_id'])
569     turb.slack_client.conversations_rename(
570         channel=puzzle['channel_id'],
571         name=channel_name)
572
573     return lambda_ok
574
575 commands["/solved"] = solved