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