]> git.cworth.org Git - turbot/blob - turbot/interaction.py
c0aff61dbc7412bbfb895f6cbec2336432bbc0a1
[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, checkbox_block
4 )
5 from turbot.hunt import find_hunt_for_hunt_id, hunt_blocks
6 from turbot.puzzle import (
7     find_puzzle_for_url,
8     find_puzzle_for_puzzle_id,
9     puzzle_update_channel_and_sheet,
10     puzzle_id_from_name,
11     puzzle_blocks
12 )
13 import turbot.rot
14 import turbot.sheets
15 import turbot.slack
16 import json
17 import re
18 import requests
19 from botocore.exceptions import ClientError
20 from boto3.dynamodb.conditions import Key
21 from turbot.slack import slack_send_message
22 import shlex
23
24 actions = {}
25 actions['button'] = {}
26 commands = {}
27 submission_handlers = {}
28
29 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
30 #
31 # Note: This restriction not only allows for hunt and puzzle ID values to
32 # be used as Slack channel names, but it also allows for '-' as a valid
33 # separator between a hunt and a puzzle ID (for example in the puzzle
34 # edit dialog where a single attribute must capture both values).
35 valid_id_re = r'^[_a-z0-9]+$'
36
37 lambda_ok = {'statusCode': 200}
38
39 def bot_reply(message):
40     """Construct a return value suitable for a bot reply
41
42     This is suitable as a way to give an error back to the user who
43     initiated a slash command, for example."""
44
45     return {
46         'statusCode': 200,
47         'body': message
48     }
49
50 def submission_error(field, error):
51     """Construct an error suitable for returning for an invalid submission.
52
53     Returning this value will prevent a submission and alert the user that
54     the given field is invalid because of the given error."""
55
56     print("Rejecting invalid modal submission: {}".format(error))
57
58     return {
59         'statusCode': 200,
60         'headers': {
61             "Content-Type": "application/json"
62         },
63         'body': json.dumps({
64             "response_action": "errors",
65             "errors": {
66                 field: error
67             }
68         })
69     }
70
71 def multi_static_select(turb, payload):
72     """Handler for the action of user entering a multi-select value"""
73
74     return lambda_ok
75
76 actions['multi_static_select'] = {"*": multi_static_select}
77
78 def edit_puzzle_command(turb, body):
79     """Implementation of the `/puzzle edit` command
80
81     As dispatched from the puzzle() function.
82     """
83
84     channel_id = body['channel_id'][0]
85     trigger_id = body['trigger_id'][0]
86
87     puzzle = puzzle_for_channel(turb, channel_id)
88
89     if not puzzle:
90         return bot_reply("Sorry, this does not appear to be a puzzle channel.")
91
92     return edit_puzzle(turb, puzzle, trigger_id)
93
94     return lambda_ok
95
96 def edit_puzzle_button(turb, payload):
97     """Handler for the action of user pressing an edit_puzzle button"""
98
99     action_id = payload['actions'][0]['action_id']
100     response_url = payload['response_url']
101     trigger_id = payload['trigger_id']
102
103     (hunt_id, puzzle_id) = action_id.split('-', 1)
104
105     puzzle = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle_id)
106
107     if not puzzle:
108         requests.post(response_url,
109                       json = {"text": "Error: Puzzle not found!"},
110                       headers = {"Content-type": "application/json"})
111         return bot_reply("Error: Puzzle not found.")
112
113     return edit_puzzle(turb, puzzle, trigger_id)
114
115 actions['button']['edit_puzzle'] = edit_puzzle_button
116
117 def edit_puzzle(turb, puzzle, trigger_id):
118     """Common code for implementing an edit puzzle dialog
119
120     This implementation is common whether the edit operation was invoked
121     by a button (edit_puzzle_button) or a command (edit_puzzle_command).
122     """
123
124     round_options = hunt_rounds(turb, puzzle['hunt_id'])
125
126     if len(round_options):
127         round_options_block = [
128             multi_select_block("Round(s)", "rounds",
129                                "Existing round(s) this puzzle belongs to",
130                                round_options,
131                                initial_options=puzzle.get("rounds", None)),
132         ]
133     else:
134         round_options_block = []
135
136     solved = False
137     if puzzle.get("status", "unsolved") == solved:
138         solved = True
139
140     solution_str = None
141     solution_list = puzzle.get("solution", [])
142     if solution_list:
143         solution_str = ", ".join(solution_list)
144
145     view = {
146         "type": "modal",
147         "private_metadata": json.dumps({
148             "hunt_id": puzzle['hunt_id'],
149             "SK": puzzle["SK"],
150             "puzzle_id": puzzle['puzzle_id'],
151             "channel_id": puzzle["channel_id"],
152             "channel_url": puzzle["channel_url"],
153             "sheet_url": puzzle["sheet_url"],
154         }),
155         "title": {"type": "plain_text", "text": "Edit Puzzle"},
156         "submit": { "type": "plain_text", "text": "Save" },
157         "blocks": [
158             input_block("Puzzle name", "name", "Name of the puzzle",
159                         initial_value=puzzle["name"]),
160             input_block("Puzzle URL", "url", "External URL of puzzle",
161                         initial_value=puzzle.get("url", None),
162                         optional=True),
163             checkbox_block("Is this a meta puzzle?", "Meta", "meta",
164                            checked=(puzzle.get('type', 'plain') == 'meta')),
165             * round_options_block,
166             input_block("New round(s)", "new_rounds",
167                         "New round(s) this puzzle belongs to " +
168                         "(comma separated)",
169                         optional=True),
170             input_block("State", "state",
171                         "State of this puzzle (partial progress, next steps)",
172                         initial_value=puzzle.get("state", None),
173                         optional=True),
174             checkbox_block(
175                 "Puzzle status", "Solved", "solved",
176                 checked=(puzzle.get('status', 'unsolved') == 'solved')),
177             input_block("Solution", "solution",
178                         "Solution(s) (comma-separated if multiple)",
179                         initial_value=solution_str,
180                         optional=True),
181         ]
182     }
183
184     result = turb.slack_client.views_open(trigger_id=trigger_id,
185                                           view=view)
186
187     if (result['ok']):
188         submission_handlers[result['view']['id']] = edit_puzzle_submission
189
190     return lambda_ok
191
192 def edit_puzzle_submission(turb, payload, metadata):
193     """Handler for the user submitting the edit puzzle modal
194
195     This is the modal view presented to the user by the edit_puzzle
196     function above.
197     """
198
199     puzzle={}
200
201     # First, read all the various data from the request
202     meta = json.loads(metadata)
203     puzzle['hunt_id'] = meta['hunt_id']
204     puzzle['SK'] = meta['SK']
205     puzzle['puzzle_id'] = meta['puzzle_id']
206     puzzle['channel_id'] = meta['channel_id']
207     puzzle['channel_url'] = meta['channel_url']
208     puzzle['sheet_url'] = meta['sheet_url']
209
210     state = payload['view']['state']['values']
211     user_id = payload['user']['id']
212
213     puzzle['name'] = state['name']['name']['value']
214     url = state['url']['url']['value']
215     if url:
216         puzzle['url'] = url
217     if state['meta']['meta']['selected_options']:
218         puzzle['type'] = 'meta'
219     else:
220         puzzle['type'] = 'plain'
221     rounds = [option['value'] for option in
222               state['rounds']['rounds']['selected_options']]
223     if rounds:
224         puzzle['rounds'] = rounds
225     new_rounds = state['new_rounds']['new_rounds']['value']
226     puzzle_state = state['state']['state']['value']
227     if puzzle_state:
228         puzzle['state'] = puzzle_state
229     if state['solved']['solved']['selected_options']:
230         puzzle['status'] = 'solved'
231     else:
232         puzzle['status'] = 'unsolved'
233     puzzle['solution'] = []
234     solution = state['solution']['solution']['value']
235     if solution:
236         puzzle['solution'] = [
237             sol.strip() for sol in solution.split(',')
238         ]
239
240     # Verify that there's a solution if the puzzle is mark solved
241     if puzzle['status'] == 'solved' and not puzzle['solution']:
242         return submission_error("solution",
243                                 "A solved puzzle requires a solution.")
244
245     if puzzle['status'] == 'unsolved' and puzzle['solution']:
246         return submission_error("solution",
247                                 "An unsolved puzzle should have no solution.")
248
249     # Add any new rounds to the database
250     if new_rounds:
251         if 'rounds' not in puzzle:
252             puzzle['rounds'] = []
253         for round in new_rounds.split(','):
254             # Drop any leading/trailing spaces from the round name
255             round = round.strip()
256             # Ignore any empty string
257             if not len(round):
258                 continue
259             puzzle['rounds'].append(round)
260             turb.table.put_item(
261                 Item={
262                     'hunt_id': puzzle['hunt_id'],
263                     'SK': 'round-' + round
264                 }
265             )
266
267     # Get old puzzle from the database (to determine what's changed)
268     old_puzzle = find_puzzle_for_puzzle_id(turb,
269                                            puzzle['hunt_id'],
270                                            puzzle['puzzle_id'])
271
272     # Update the puzzle in the database
273     turb.table.put_item(Item=puzzle)
274
275     # Inform the puzzle channel about the edit
276     edit_message = "Puzzle edited by <@{}>".format(user_id)
277     blocks = ([section_block(text_block(edit_message+":\n"))] +
278               puzzle_blocks(puzzle, include_rounds=True))
279     slack_send_message(
280         turb.slack_client, puzzle['channel_id'],
281         edit_message, blocks=blocks)
282
283     # Also inform the hunt if the puzzle's solved status changed
284     if puzzle['status'] != old_puzzle['status']:
285         hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
286         if puzzle['status'] == 'solved':
287             message = "Puzzle <{}|{}> has been solved!".format(
288                 puzzle['channel_url'],
289                 puzzle['name'])
290         else:
291             message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
292                 puzzle['channel_url'],
293                 puzzle['name'])
294         slack_send_message(turb.slack_client, hunt['channel_id'], message)
295
296     # We need to set the channel topic if any of puzzle name, url,
297     # state, status, or solution, has changed. Let's just do that
298     # unconditionally here.
299     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
300
301     return lambda_ok
302
303 def new_hunt(turb, payload):
304     """Handler for the action of user pressing the new_hunt button"""
305
306     view = {
307         "type": "modal",
308         "private_metadata": json.dumps({}),
309         "title": { "type": "plain_text", "text": "New Hunt" },
310         "submit": { "type": "plain_text", "text": "Create" },
311         "blocks": [
312             input_block("Hunt name", "name", "Name of the hunt"),
313             input_block("Hunt ID", "hunt_id",
314                         "Used as puzzle channel prefix "
315                         + "(no spaces nor punctuation)"),
316             input_block("Hunt URL", "url", "External URL of hunt",
317                         optional=True)
318         ],
319     }
320
321     result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
322                                           view=view)
323     if (result['ok']):
324         submission_handlers[result['view']['id']] = new_hunt_submission
325
326     return lambda_ok
327
328 actions['button']['new_hunt'] = new_hunt
329
330 def new_hunt_submission(turb, payload, metadata):
331     """Handler for the user submitting the new hunt modal
332
333     This is the modal view presented to the user by the new_hunt
334     function above."""
335
336     state = payload['view']['state']['values']
337     user_id = payload['user']['id']
338     name = state['name']['name']['value']
339     hunt_id = state['hunt_id']['hunt_id']['value']
340     url = state['url']['url']['value']
341
342     # Validate that the hunt_id contains no invalid characters
343     if not re.match(valid_id_re, hunt_id):
344         return submission_error("hunt_id",
345                                 "Hunt ID can only contain lowercase letters, "
346                                 + "numbers, and underscores")
347
348     # Check to see if the turbot table exists
349     try:
350         exists = turb.table.table_status in ("CREATING", "UPDATING",
351                                              "ACTIVE")
352     except ClientError:
353         exists = False
354
355     # Create the turbot table if necessary.
356     if not exists:
357         turb.table = turb.db.create_table(
358             TableName='turbot',
359             KeySchema=[
360                 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
361                 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
362             ],
363             AttributeDefinitions=[
364                 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
365                 {'AttributeName': 'SK', 'AttributeType': 'S'},
366                 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
367                 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
368                 {'AttributeName': 'url', 'AttributeType': 'S'}
369             ],
370             ProvisionedThroughput={
371                 'ReadCapacityUnits': 5,
372                 'WriteCapacityUnits': 5
373             },
374             GlobalSecondaryIndexes=[
375                 {
376                     'IndexName': 'channel_id_index',
377                     'KeySchema': [
378                         {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
379                     ],
380                     'Projection': {
381                         'ProjectionType': 'ALL'
382                     },
383                     'ProvisionedThroughput': {
384                         'ReadCapacityUnits': 5,
385                         'WriteCapacityUnits': 5
386                     }
387                 },
388                 {
389                     'IndexName': 'is_hunt_index',
390                     'KeySchema': [
391                         {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
392                     ],
393                     'Projection': {
394                         'ProjectionType': 'ALL'
395                     },
396                     'ProvisionedThroughput': {
397                         'ReadCapacityUnits': 5,
398                         'WriteCapacityUnits': 5
399                     }
400                 }
401             ],
402             LocalSecondaryIndexes = [
403                 {
404                     'IndexName': 'url_index',
405                     'KeySchema': [
406                         {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
407                         {'AttributeName': 'url', 'KeyType': 'RANGE'},
408                     ],
409                     'Projection': {
410                         'ProjectionType': 'ALL'
411                     }
412                 }
413             ]
414         )
415         return submission_error(
416             "hunt_id",
417             "Still bootstrapping turbot table. Try again in a minute, please.")
418
419     # Create a channel for the hunt
420     try:
421         response = turb.slack_client.conversations_create(name=hunt_id)
422     except SlackApiError as e:
423         return submission_error("hunt_id",
424                                 "Error creating Slack channel: {}"
425                                 .format(e.response['error']))
426
427     channel_id = response['channel']['id']
428
429     # Insert the newly-created hunt into the database
430     # (leaving it as non-active for now until the channel-created handler
431     #  finishes fixing it up with a sheet and a companion table)
432     item={
433         "hunt_id": hunt_id,
434         "SK": "hunt-{}".format(hunt_id),
435         "is_hunt": hunt_id,
436         "channel_id": channel_id,
437         "active": False,
438         "name": name,
439     }
440     if url:
441         item['url'] = url
442     turb.table.put_item(Item=item)
443
444     # Invite the initiating user to the channel
445     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
446
447     return lambda_ok
448
449 def view_submission(turb, payload):
450     """Handler for Slack interactive view submission
451
452     Specifically, those that have a payload type of 'view_submission'"""
453
454     view_id = payload['view']['id']
455     metadata = payload['view']['private_metadata']
456
457     if view_id in submission_handlers:
458         return submission_handlers[view_id](turb, payload, metadata)
459
460     print("Error: Unknown view ID: {}".format(view_id))
461     return {
462         'statusCode': 400
463     }
464
465 def rot(turb, body, args):
466     """Implementation of the /rot command
467
468     The args string should be as follows:
469
470         [count|*] String to be rotated
471
472     That is, the first word of the string is an optional number (or
473     the character '*'). If this is a number it indicates an amount to
474     rotate each character in the string. If the count is '*' or is not
475     present, then the string will be rotated through all possible 25
476     values.
477
478     The result of the rotation is returned (with Slack formatting) in
479     the body of the response so that Slack will provide it as a reply
480     to the user who submitted the slash command."""
481
482     channel_name = body['channel_name'][0]
483     response_url = body['response_url'][0]
484     channel_id = body['channel_id'][0]
485
486     result = turbot.rot.rot(args)
487
488     if (channel_name == "directmessage"):
489         requests.post(response_url,
490                       json = {"text": result},
491                       headers = {"Content-type": "application/json"})
492     else:
493         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
494
495     return lambda_ok
496
497 commands["/rot"] = rot
498
499 def get_table_item(turb, table_name, key, value):
500     """Get an item from the database 'table_name' with 'key' as 'value'
501
502     Returns a tuple of (item, table) if found and (None, None) otherwise."""
503
504     table = turb.db.Table(table_name)
505
506     response = table.get_item(Key={key: value})
507
508     if 'Item' in response:
509         return (response['Item'], table)
510     else:
511         return (None, None)
512
513 def db_entry_for_channel(turb, channel_id):
514     """Given a channel ID return the database item for this channel
515
516     If this channel is a registered hunt or puzzle channel, return the
517     corresponding row from the database for this channel. Otherwise,
518     return None.
519
520     Note: If you need to specifically ensure that the channel is a
521     puzzle or a hunt, please call puzzle_for_channel or
522     hunt_for_channel respectively.
523     """
524
525     response = turb.table.query(
526         IndexName = "channel_id_index",
527         KeyConditionExpression=Key("channel_id").eq(channel_id)
528     )
529
530     if response['Count'] == 0:
531         return None
532
533     return response['Items'][0]
534
535
536 def puzzle_for_channel(turb, channel_id):
537
538     """Given a channel ID return the puzzle from the database for this channel
539
540     If the given channel_id is a puzzle's channel, this function
541     returns a dict filled with the attributes from the puzzle's entry
542     in the database.
543
544     Otherwise, this function returns None.
545     """
546
547     entry = db_entry_for_channel(turb, channel_id)
548
549     if entry and entry['SK'].startswith('puzzle-'):
550         return entry
551     else:
552         return None
553
554 def hunt_for_channel(turb, channel_id):
555
556     """Given a channel ID return the hunt from the database for this channel
557
558     This works whether the original channel is a primary hunt channel,
559     or if it is one of the channels of a puzzle belonging to the hunt.
560
561     Returns None if channel does not belong to a hunt, otherwise a
562     dictionary with all fields from the hunt's row in the table,
563     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
564     """
565
566     entry = db_entry_for_channel(turb, channel_id)
567
568     # We're done if this channel doesn't exist in the database at all
569     if not entry:
570         return None
571
572     # Also done if this channel is a hunt channel
573     if entry['SK'].startswith('hunt-'):
574         return entry
575
576     # Otherwise, (the channel is in the database, but is not a hunt),
577     # we expect this to be a puzzle channel instead
578     return find_hunt_for_hunt_id(turb, entry['hunt_id'])
579
580 # python3.9 has a built-in removeprefix but AWS only has python3.8
581 def remove_prefix(text, prefix):
582     if text.startswith(prefix):
583         return text[len(prefix):]
584     return text
585
586 def hunt_rounds(turb, hunt_id):
587     """Returns array of strings giving rounds that exist in the given hunt"""
588
589     response = turb.table.query(
590         KeyConditionExpression=(
591             Key('hunt_id').eq(hunt_id) &
592             Key('SK').begins_with('round-')
593         )
594     )
595
596     if response['Count'] == 0:
597         return []
598
599     return [remove_prefix(option['SK'], 'round-')
600             for option in response['Items']]
601
602 def puzzle(turb, body, args):
603     """Implementation of the /puzzle command
604
605     The args string can be a sub-command:
606
607         /puzzle new: Bring up a dialog to create a new puzzle
608
609         /puzzle edit: Edit the puzzle for the current channel
610
611     Or with no argument at all:
612
613         /puzzle: Print details of the current puzzle (if in a puzzle channel)
614     """
615
616     if args == 'new':
617         return new_puzzle(turb, body)
618
619     if args == 'edit':
620         return edit_puzzle_command(turb, body)
621
622     if len(args):
623         return bot_reply("Unknown syntax for `/puzzle` command. " +
624                          "Valid commands are: `/puzzle`, `/puzzle edit`, " +
625                          "and `/puzzle new` to display, edit, or create " +
626                          "a puzzle.")
627
628     # For no arguments we print the current puzzle as a reply
629     channel_id = body['channel_id'][0]
630     response_url = body['response_url'][0]
631
632     puzzle = puzzle_for_channel(turb, channel_id)
633
634     if not puzzle:
635         hunt = hunt_for_channel(turb, channel_id)
636         if hunt:
637             return bot_reply(
638                 "This is not a puzzle channel, but is a hunt channel. "
639                 + "If you want to create a new puzzle for this hunt, use "
640                 + "`/puzzle new`.")
641         else:
642             return bot_reply(
643                 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
644                 + "channel, so the `/puzzle` command cannot work here.")
645
646     blocks = puzzle_blocks(puzzle, include_rounds=True)
647
648     requests.post(response_url,
649                   json = {'blocks': blocks},
650                   headers = {'Content-type': 'application/json'}
651                   )
652
653     return lambda_ok
654
655 commands["/puzzle"] = puzzle
656
657 def new_puzzle(turb, body):
658     """Implementation of the "/puzzle new" command
659
660     This brings up a dialog box for creating a new puzzle.
661     """
662
663     channel_id = body['channel_id'][0]
664     trigger_id = body['trigger_id'][0]
665
666     hunt = hunt_for_channel(turb, channel_id)
667
668     if not hunt:
669         return bot_reply("Sorry, this channel doesn't appear to "
670                          + "be a hunt or puzzle channel")
671
672     round_options = hunt_rounds(turb, hunt['hunt_id'])
673
674     if len(round_options):
675         round_options_block = [
676             multi_select_block("Round(s)", "rounds",
677                                "Existing round(s) this puzzle belongs to",
678                                round_options)
679         ]
680     else:
681         round_options_block = []
682
683     view = {
684         "type": "modal",
685         "private_metadata": json.dumps({
686             "hunt_id": hunt['hunt_id'],
687         }),
688         "title": {"type": "plain_text", "text": "New Puzzle"},
689         "submit": { "type": "plain_text", "text": "Create" },
690         "blocks": [
691             section_block(text_block("*For {}*".format(hunt['name']))),
692             input_block("Puzzle name", "name", "Name of the puzzle"),
693             input_block("Puzzle URL", "url", "External URL of puzzle",
694                         optional=True),
695             checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
696             * round_options_block,
697             input_block("New round(s)", "new_rounds",
698                         "New round(s) this puzzle belongs to " +
699                         "(comma separated)",
700                         optional=True)
701         ]
702     }
703
704     result = turb.slack_client.views_open(trigger_id=trigger_id,
705                                           view=view)
706
707     if (result['ok']):
708         submission_handlers[result['view']['id']] = new_puzzle_submission
709
710     return lambda_ok
711
712 def new_puzzle_submission(turb, payload, metadata):
713     """Handler for the user submitting the new puzzle modal
714
715     This is the modal view presented to the user by the new_puzzle
716     function above.
717     """
718
719     # First, read all the various data from the request
720     meta = json.loads(metadata)
721     hunt_id = meta['hunt_id']
722
723     state = payload['view']['state']['values']
724     name = state['name']['name']['value']
725     url = state['url']['url']['value']
726     if state['meta']['meta']['selected_options']:
727         puzzle_type = 'meta'
728     else:
729         puzzle_type = 'plain'
730     if 'rounds' in state:
731         rounds = [option['value'] for option in
732                   state['rounds']['rounds']['selected_options']]
733     else:
734         rounds = []
735     new_rounds = state['new_rounds']['new_rounds']['value']
736
737     # Before doing anything, reject this puzzle if a puzzle already
738     # exists with the same URL.
739     if url:
740         existing = find_puzzle_for_url(turb, hunt_id, url)
741         if existing:
742             return submission_error(
743                 "url",
744                 "Error: A puzzle with this URL already exists.")
745
746     # Create a Slack-channel-safe puzzle_id
747     puzzle_id = puzzle_id_from_name(name)
748
749     # Create a channel for the puzzle
750     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
751
752     try:
753         response = turb.slack_client.conversations_create(
754             name=hunt_dash_channel)
755     except SlackApiError as e:
756         return submission_error(
757             "name",
758             "Error creating Slack channel {}: {}"
759             .format(hunt_dash_channel, e.response['error']))
760
761     channel_id = response['channel']['id']
762
763     # Add any new rounds to the database
764     if new_rounds:
765         for round in new_rounds.split(','):
766             # Drop any leading/trailing spaces from the round name
767             round = round.strip()
768             # Ignore any empty string
769             if not len(round):
770                 continue
771             rounds.append(round)
772             turb.table.put_item(
773                 Item={
774                     'hunt_id': hunt_id,
775                     'SK': 'round-' + round
776                 }
777             )
778
779     # Construct a puzzle dict
780     puzzle = {
781         "hunt_id": hunt_id,
782         "SK": "puzzle-{}".format(puzzle_id),
783         "puzzle_id": puzzle_id,
784         "channel_id": channel_id,
785         "solution": [],
786         "status": 'unsolved',
787         "name": name,
788         "type": puzzle_type
789     }
790     if url:
791         puzzle['url'] = url
792     if rounds:
793         puzzle['rounds'] = rounds
794
795     # Insert the newly-created puzzle into the database
796     turb.table.put_item(Item=puzzle)
797
798     return lambda_ok
799
800 def state(turb, body, args):
801     """Implementation of the /state command
802
803     The args string should be a brief sentence describing where things
804     stand or what's needed."""
805
806     channel_id = body['channel_id'][0]
807
808     old_puzzle = puzzle_for_channel(turb, channel_id)
809
810     if not old_puzzle:
811         return bot_reply(
812             "Sorry, the /state command only works in a puzzle channel")
813
814     # Make a copy of the puzzle object
815     puzzle = old_puzzle.copy()
816
817     # Update the puzzle in the database
818     puzzle['state'] = args
819     turb.table.put_item(Item=puzzle)
820
821     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
822
823     return lambda_ok
824
825 commands["/state"] = state
826
827 def solved(turb, body, args):
828     """Implementation of the /solved command
829
830     The args string should be a confirmed solution."""
831
832     channel_id = body['channel_id'][0]
833     user_name = body['user_name'][0]
834
835     old_puzzle = puzzle_for_channel(turb, channel_id)
836
837     if not old_puzzle:
838         return bot_reply("Sorry, this is not a puzzle channel.")
839
840     if not args:
841         return bot_reply(
842             "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
843
844     # Make a copy of the puzzle object
845     puzzle = old_puzzle.copy()
846
847     # Set the status and solution fields in the database
848     puzzle['status'] = 'solved'
849     puzzle['solution'].append(args)
850     if 'state' in puzzle:
851         del puzzle['state']
852     turb.table.put_item(Item=puzzle)
853
854     # Report the solution to the puzzle's channel
855     slack_send_message(
856         turb.slack_client, channel_id,
857         "Puzzle mark solved by {}: `{}`".format(user_name, args))
858
859     # Also report the solution to the hunt channel
860     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
861     slack_send_message(
862         turb.slack_client, hunt['channel_id'],
863         "Puzzle <{}|{}> has been solved!".format(
864             puzzle['channel_url'],
865             puzzle['name'])
866     )
867
868     # And update the puzzle's description
869     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
870
871     return lambda_ok
872
873 commands["/solved"] = solved
874
875 def hunt(turb, body, args):
876     """Implementation of the /hunt command
877
878     The (optional) args string can be used to filter which puzzles to
879     display. The first word can be one of 'all', 'unsolved', or
880     'solved' and can be used to display only puzzles with the given
881     status. If this first word is missing, this command will display
882     only unsolved puzzles by default.
883
884     Any remaining text in the args string will be interpreted as
885     search terms. These will be split into separate terms on space
886     characters, (though quotation marks can be used to include a space
887     character in a term). All terms must match on a puzzle in order
888     for that puzzle to be included. But a puzzle will be considered to
889     match if any of the puzzle title, round title, puzzle URL, puzzle
890     state, or puzzle solution match. Matching will be performed
891     without regard to case sensitivity and the search terms can
892     include regular expression syntax.
893     """
894
895     channel_id = body['channel_id'][0]
896     response_url = body['response_url'][0]
897
898     terms = None
899     if args:
900         # The first word can be a puzzle status and all remaining word
901         # (if any) are search terms. _But_, if the first word is not a
902         # valid puzzle status ('all', 'unsolved', 'solved'), then all
903         # words are search terms and we default status to 'unsolved'.
904         split_args = args.split(' ', 1)
905         status = split_args[0]
906         if (len(split_args) > 1):
907             terms = split_args[1]
908         if status not in ('unsolved', 'solved', 'all'):
909             terms = args
910             status = 'unsolved'
911     else:
912         status = 'unsolved'
913
914     # Separate search terms on spaces (but allow for quotation marks
915     # to capture spaces in a search term)
916     if terms:
917         terms = shlex.split(terms)
918
919     hunt = hunt_for_channel(turb, channel_id)
920
921     if not hunt:
922         return bot_reply("Sorry, this channel doesn't appear to "
923                          + "be a hunt or puzzle channel")
924
925     blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
926
927     requests.post(response_url,
928                   json = { 'blocks': blocks },
929                   headers = {'Content-type': 'application/json'}
930                   )
931
932     return lambda_ok
933
934 commands["/hunt"] = hunt
935
936 def round(turb, body, args):
937     """Implementation of the /round command
938
939     Displays puzzles in the same round(s) as the puzzle for the
940     current channel.
941
942     The (optional) args string can be used to filter which puzzles to
943     display. The first word can be one of 'all', 'unsolved', or
944     'solved' and can be used to display only puzzles with the given
945     status. If this first word is missing, this command will display
946     all puzzles in the round by default.
947
948     Any remaining text in the args string will be interpreted as
949     search terms. These will be split into separate terms on space
950     characters, (though quotation marks can be used to include a space
951     character in a term). All terms must match on a puzzle in order
952     for that puzzle to be included. But a puzzle will be considered to
953     match if any of the puzzle title, round title, puzzle URL, puzzle
954     state, or puzzle solution match. Matching will be performed
955     without regard to case sensitivity and the search terms can
956     include regular expression syntax.
957     """
958
959     channel_id = body['channel_id'][0]
960     response_url = body['response_url'][0]
961
962     puzzle = puzzle_for_channel(turb, channel_id)
963     hunt = hunt_for_channel(turb, channel_id)
964
965     if not puzzle:
966         if hunt:
967             return bot_reply(
968                 "This is not a puzzle channel, but is a hunt channel. "
969                 + "Use /hunt if you want to see all rounds for this hunt.")
970         else:
971             return bot_reply(
972                 "Sorry, this channel doesn't appear to be a puzzle channel "
973                 + "so the `/round` command cannot work here.")
974
975     terms = None
976     if args:
977         # The first word can be a puzzle status and all remaining word
978         # (if any) are search terms. _But_, if the first word is not a
979         # valid puzzle status ('all', 'unsolved', 'solved'), then all
980         # words are search terms and we default status to 'unsolved'.
981         split_args = args.split(' ', 1)
982         status = split_args[0]
983         if (len(split_args) > 1):
984             terms = split_args[1]
985         if status not in ('unsolved', 'solved', 'all'):
986             terms = args
987             status = 'all'
988     else:
989         status = 'all'
990
991     # Separate search terms on spaces (but allow for quotation marks
992     # to capture spaces in a search term)
993     if terms:
994         terms = shlex.split(terms)
995
996     blocks = hunt_blocks(turb, hunt,
997                          puzzle_status=status, search_terms=terms,
998                          limit_to_rounds=puzzle.get('rounds', [])
999                          )
1000
1001     requests.post(response_url,
1002                   json = { 'blocks': blocks },
1003                   headers = {'Content-type': 'application/json'}
1004                   )
1005
1006     return lambda_ok
1007
1008 commands["/round"] = round