]> git.cworth.org Git - turbot/blob - turbot/interaction.py
b22016107df99ae5127e4101d6cbe87abc0c4a3e
[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     # Insert the newly-created puzzle into the database
780     item={
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         item['url'] = url
792     if rounds:
793         item['rounds'] = rounds
794     turb.table.put_item(Item=item)
795
796     return lambda_ok
797
798 def state(turb, body, args):
799     """Implementation of the /state command
800
801     The args string should be a brief sentence describing where things
802     stand or what's needed."""
803
804     channel_id = body['channel_id'][0]
805
806     old_puzzle = puzzle_for_channel(turb, channel_id)
807
808     if not old_puzzle:
809         return bot_reply(
810             "Sorry, the /state command only works in a puzzle channel")
811
812     # Make a copy of the puzzle object
813     puzzle = old_puzzle.copy()
814
815     # Update the puzzle in the database
816     puzzle['state'] = args
817     turb.table.put_item(Item=puzzle)
818
819     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
820
821     return lambda_ok
822
823 commands["/state"] = state
824
825 def solved(turb, body, args):
826     """Implementation of the /solved command
827
828     The args string should be a confirmed solution."""
829
830     channel_id = body['channel_id'][0]
831     user_name = body['user_name'][0]
832
833     old_puzzle = puzzle_for_channel(turb, channel_id)
834
835     if not old_puzzle:
836         return bot_reply("Sorry, this is not a puzzle channel.")
837
838     if not args:
839         return bot_reply(
840             "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
841
842     # Make a copy of the puzzle object
843     puzzle = old_puzzle.copy()
844
845     # Set the status and solution fields in the database
846     puzzle['status'] = 'solved'
847     puzzle['solution'].append(args)
848     if 'state' in puzzle:
849         del puzzle['state']
850     turb.table.put_item(Item=puzzle)
851
852     # Report the solution to the puzzle's channel
853     slack_send_message(
854         turb.slack_client, channel_id,
855         "Puzzle mark solved by {}: `{}`".format(user_name, args))
856
857     # Also report the solution to the hunt channel
858     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
859     slack_send_message(
860         turb.slack_client, hunt['channel_id'],
861         "Puzzle <{}|{}> has been solved!".format(
862             puzzle['channel_url'],
863             puzzle['name'])
864     )
865
866     # And update the puzzle's description
867     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
868
869     return lambda_ok
870
871 commands["/solved"] = solved
872
873 def hunt(turb, body, args):
874     """Implementation of the /hunt command
875
876     The (optional) args string can be used to filter which puzzles to
877     display. The first word can be one of 'all', 'unsolved', or
878     'solved' and can be used to display only puzzles with the given
879     status. If this first word is missing, this command will display
880     only unsolved puzzles by default.
881
882     Any remaining text in the args string will be interpreted as
883     search terms. These will be split into separate terms on space
884     characters, (though quotation marks can be used to include a space
885     character in a term). All terms must match on a puzzle in order
886     for that puzzle to be included. But a puzzle will be considered to
887     match if any of the puzzle title, round title, puzzle URL, puzzle
888     state, or puzzle solution match. Matching will be performed
889     without regard to case sensitivity and the search terms can
890     include regular expression syntax.
891     """
892
893     channel_id = body['channel_id'][0]
894     response_url = body['response_url'][0]
895
896     terms = None
897     if args:
898         # The first word can be a puzzle status and all remaining word
899         # (if any) are search terms. _But_, if the first word is not a
900         # valid puzzle status ('all', 'unsolved', 'solved'), then all
901         # words are search terms and we default status to 'unsolved'.
902         split_args = args.split(' ', 1)
903         status = split_args[0]
904         if (len(split_args) > 1):
905             terms = split_args[1]
906         if status not in ('unsolved', 'solved', 'all'):
907             terms = args
908             status = 'unsolved'
909     else:
910         status = 'unsolved'
911
912     # Separate search terms on spaces (but allow for quotation marks
913     # to capture spaces in a search term)
914     if terms:
915         terms = shlex.split(terms)
916
917     hunt = hunt_for_channel(turb, channel_id)
918
919     if not hunt:
920         return bot_reply("Sorry, this channel doesn't appear to "
921                          + "be a hunt or puzzle channel")
922
923     blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
924
925     requests.post(response_url,
926                   json = { 'blocks': blocks },
927                   headers = {'Content-type': 'application/json'}
928                   )
929
930     return lambda_ok
931
932 commands["/hunt"] = hunt
933
934 def round(turb, body, args):
935     """Implementation of the /round command
936
937     Displays puzzles in the same round(s) as the puzzle for the
938     current channel.
939
940     The (optional) args string can be used to filter which puzzles to
941     display. The first word can be one of 'all', 'unsolved', or
942     'solved' and can be used to display only puzzles with the given
943     status. If this first word is missing, this command will display
944     all puzzles in the round by default.
945
946     Any remaining text in the args string will be interpreted as
947     search terms. These will be split into separate terms on space
948     characters, (though quotation marks can be used to include a space
949     character in a term). All terms must match on a puzzle in order
950     for that puzzle to be included. But a puzzle will be considered to
951     match if any of the puzzle title, round title, puzzle URL, puzzle
952     state, or puzzle solution match. Matching will be performed
953     without regard to case sensitivity and the search terms can
954     include regular expression syntax.
955     """
956
957     channel_id = body['channel_id'][0]
958     response_url = body['response_url'][0]
959
960     puzzle = puzzle_for_channel(turb, channel_id)
961     hunt = hunt_for_channel(turb, channel_id)
962
963     if not puzzle:
964         if hunt:
965             return bot_reply(
966                 "This is not a puzzle channel, but is a hunt channel. "
967                 + "Use /hunt if you want to see all rounds for this hunt.")
968         else:
969             return bot_reply(
970                 "Sorry, this channel doesn't appear to be a puzzle channel "
971                 + "so the `/round` command cannot work here.")
972
973     terms = None
974     if args:
975         # The first word can be a puzzle status and all remaining word
976         # (if any) are search terms. _But_, if the first word is not a
977         # valid puzzle status ('all', 'unsolved', 'solved'), then all
978         # words are search terms and we default status to 'unsolved'.
979         split_args = args.split(' ', 1)
980         status = split_args[0]
981         if (len(split_args) > 1):
982             terms = split_args[1]
983         if status not in ('unsolved', 'solved', 'all'):
984             terms = args
985             status = 'all'
986     else:
987         status = 'all'
988
989     # Separate search terms on spaces (but allow for quotation marks
990     # to capture spaces in a search term)
991     if terms:
992         terms = shlex.split(terms)
993
994     blocks = hunt_blocks(turb, hunt,
995                          puzzle_status=status, search_terms=terms,
996                          limit_to_rounds=puzzle.get('rounds', [])
997                          )
998
999     requests.post(response_url,
1000                   json = { 'blocks': blocks },
1001                   headers = {'Content-type': 'application/json'}
1002                   )
1003
1004     return lambda_ok
1005
1006 commands["/round"] = round