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