1 from slack.errors import SlackApiError
2 from turbot.blocks import (
3 input_block, section_block, text_block, multi_select_block, checkbox_block
5 from turbot.hunt import (
8 hunt_puzzles_for_hunt_id
10 from turbot.puzzle import (
12 find_puzzle_for_sort_key,
13 find_puzzle_for_puzzle_id,
14 puzzle_update_channel_and_sheet,
21 from turbot.round import round_quoted_puzzles_titles_answers
22 from turbot.help import turbot_help
23 from turbot.have_you_tried import have_you_tried
30 from botocore.exceptions import ClientError
31 from boto3.dynamodb.conditions import Key
32 from turbot.slack import slack_send_message
36 actions['button'] = {}
38 submission_handlers = {}
40 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
42 # Note: This restriction not only allows for hunt and puzzle ID values to
43 # be used as Slack channel names, but it also allows for '-' as a valid
44 # separator between a hunt and a puzzle ID (for example in the puzzle
45 # edit dialog where a single attribute must capture both values).
46 valid_id_re = r'^[_a-z0-9]+$'
48 lambda_ok = {'statusCode': 200}
50 def bot_reply(message):
51 """Construct a return value suitable for a bot reply
53 This is suitable as a way to give an error back to the user who
54 initiated a slash command, for example."""
61 def submission_error(field, error):
62 """Construct an error suitable for returning for an invalid submission.
64 Returning this value will prevent a submission and alert the user that
65 the given field is invalid because of the given error."""
67 print("Rejecting invalid modal submission: {}".format(error))
72 "Content-Type": "application/json"
75 "response_action": "errors",
82 def multi_static_select(turb, payload):
83 """Handler for the action of user entering a multi-select value"""
87 actions['multi_static_select'] = {"*": multi_static_select}
89 def edit(turb, body, args):
91 """Implementation of the `/edit` command
93 This can be used as `/edit` (with no arguments) in either a hunt
94 or a puzzle channel to edit that hunt or puzzle. It can also be
95 called explicitly as `/edit hunt` to edit a hunt even from a
98 In any case, the operation is identical to `/hunt edit` or
102 # If we have an explicit argument, do what it says to do
104 return edit_hunt_command(turb, body)
107 return edit_puzzle_command(turb, body)
109 # Any other argument string is an error
111 return bot_reply("Error: Unexpected argument: {}\n".format(args) +
112 "Usage: `/edit puzzle`, `/edit hunt`, or " +
113 "`/edit` (to choose based on channel)"
116 # No explicit argument, so select what to edit based on the current channel
117 channel_id = body['channel_id'][0]
118 trigger_id = body['trigger_id'][0]
120 puzzle = puzzle_for_channel(turb, channel_id)
122 return edit_puzzle(turb, puzzle, trigger_id)
124 hunt = hunt_for_channel(turb, channel_id)
126 return edit_hunt(turb, hunt, trigger_id)
128 return bot_reply("Sorry, `/edit` only works in a hunt or puzzle channel.")
130 commands["/edit"] = edit
133 def edit_puzzle_command(turb, body):
134 """Implementation of the `/puzzle edit` command
136 As dispatched from the puzzle() function.
139 channel_id = body['channel_id'][0]
140 trigger_id = body['trigger_id'][0]
142 puzzle = puzzle_for_channel(turb, channel_id)
145 return bot_reply("Sorry, this does not appear to be a puzzle channel.")
147 return edit_puzzle(turb, puzzle, trigger_id)
149 def edit_puzzle_button(turb, payload):
150 """Handler for the action of user pressing an edit_puzzle button"""
152 action_id = payload['actions'][0]['action_id']
153 trigger_id = payload['trigger_id']
155 (hunt_id, sort_key) = action_id.split('-', 1)
157 puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
160 return bot_reply("Error: Puzzle not found.")
162 return edit_puzzle(turb, puzzle, trigger_id)
164 actions['button']['edit_puzzle'] = edit_puzzle_button
166 def edit_puzzle(turb, puzzle, trigger_id):
167 """Common code for implementing an edit puzzle dialog
169 This implementation is common whether the edit operation was invoked
170 by a button (edit_puzzle_button) or a command (edit_puzzle_command).
173 round_options = hunt_rounds(turb, puzzle['hunt_id'])
175 if len(round_options):
176 round_options_block = [
177 multi_select_block("Round(s)", "rounds",
178 "Existing round(s) this puzzle belongs to",
180 initial_options=puzzle.get("rounds", None)),
183 round_options_block = []
186 if puzzle.get("status", "unsolved") == solved:
190 solution_set = puzzle.get("solution", set())
192 solution_str = ", ".join(solution_set)
196 "private_metadata": json.dumps({
197 "hunt_id": puzzle['hunt_id'],
199 "puzzle_id": puzzle['puzzle_id'],
200 "channel_id": puzzle["channel_id"],
201 "channel_url": puzzle["channel_url"],
202 "sheet_url": puzzle["sheet_url"],
204 "title": {"type": "plain_text", "text": "Edit Puzzle"},
205 "submit": { "type": "plain_text", "text": "Save" },
207 input_block("Puzzle name", "name", "Name of the puzzle",
208 initial_value=puzzle["name"]),
209 input_block("Puzzle URL", "url", "External URL of puzzle",
210 initial_value=puzzle.get("url", None),
212 checkbox_block("Is this a meta puzzle?", "Meta", "meta",
213 checked=(puzzle.get('type', 'plain') == 'meta')),
214 * round_options_block,
215 input_block("New round(s)", "new_rounds",
216 "New round(s) this puzzle belongs to " +
219 input_block("State", "state",
220 "State of this puzzle (partial progress, next steps)",
221 initial_value=puzzle.get("state", None),
224 "Puzzle status", "Solved", "solved",
225 checked=(puzzle.get('status', 'unsolved') == 'solved')),
226 input_block("Solution", "solution",
227 "Solution(s) (comma-separated if multiple)",
228 initial_value=solution_str,
233 result = turb.slack_client.views_open(trigger_id=trigger_id,
237 submission_handlers[result['view']['id']] = edit_puzzle_submission
241 def edit_puzzle_submission(turb, payload, metadata):
242 """Handler for the user submitting the edit puzzle modal
244 This is the modal view presented to the user by the edit_puzzle
250 # First, read all the various data from the request
251 meta = json.loads(metadata)
252 puzzle['hunt_id'] = meta['hunt_id']
253 puzzle['SK'] = meta['SK']
254 puzzle['puzzle_id'] = meta['puzzle_id']
255 puzzle['channel_id'] = meta['channel_id']
256 puzzle['channel_url'] = meta['channel_url']
257 puzzle['sheet_url'] = meta['sheet_url']
259 state = payload['view']['state']['values']
260 user_id = payload['user']['id']
262 puzzle['name'] = state['name']['name']['value']
263 url = state['url']['url']['value']
266 if state['meta']['meta']['selected_options']:
267 puzzle['type'] = 'meta'
269 puzzle['type'] = 'plain'
270 if 'rounds' in state:
271 rounds = [option['value'] for option in
272 state['rounds']['rounds']['selected_options']]
274 puzzle['rounds'] = rounds
275 new_rounds = state['new_rounds']['new_rounds']['value']
276 puzzle_state = state['state']['state']['value']
278 puzzle['state'] = puzzle_state
279 if state['solved']['solved']['selected_options']:
280 puzzle['status'] = 'solved'
282 puzzle['status'] = 'unsolved'
283 puzzle['solution'] = set()
284 solution = state['solution']['solution']['value']
286 puzzle['solution'] = {
287 sol.strip() for sol in solution.split(',')
290 # Verify that there's a solution if the puzzle is mark solved
291 if puzzle['status'] == 'solved' and not puzzle['solution']:
292 return submission_error("solution",
293 "A solved puzzle requires a solution.")
295 if puzzle['status'] == 'unsolved' and puzzle['solution']:
296 return submission_error("solution",
297 "An unsolved puzzle should have no solution.")
299 # Add any new rounds to the database
301 if 'rounds' not in puzzle:
302 puzzle['rounds'] = []
303 for round in new_rounds.split(','):
304 # Drop any leading/trailing spaces from the round name
305 round = round.strip()
306 # Ignore any empty string
309 puzzle['rounds'].append(round)
312 'hunt_id': puzzle['hunt_id'],
313 'SK': 'round-' + round
317 # Get old puzzle from the database (to determine what's changed)
318 old_puzzle = find_puzzle_for_sort_key(turb,
322 # If we are changing puzzle type (meta -> plain or plain -> meta)
323 # the the sort key has to change, so compute the new one and delete
324 # the old item from the database.
326 # XXX: We should really be using a transaction here to combine the
327 # delete_item and the put_item into a single transaction, but
328 # the boto interface is annoying in that transactions are only on
329 # the "Client" object which has a totally different interface than
330 # the "Table" object I've been using so I haven't figured out how
333 if puzzle['type'] != old_puzzle.get('type', 'plain'):
334 puzzle['SK'] = puzzle_sort_key(puzzle)
335 turb.table.delete_item(Key={
336 'hunt_id': old_puzzle['hunt_id'],
337 'SK': old_puzzle['SK']
340 # Update the puzzle in the database
341 turb.table.put_item(Item=puzzle)
343 # Inform the puzzle channel about the edit
344 edit_message = "Puzzle edited by <@{}>".format(user_id)
345 blocks = ([section_block(text_block(edit_message+":\n"))] +
346 puzzle_blocks(puzzle, include_rounds=True))
348 turb.slack_client, puzzle['channel_id'],
349 edit_message, blocks=blocks)
351 # Also inform the hunt if the puzzle's solved status changed
352 if puzzle['status'] != old_puzzle['status']:
353 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
354 if puzzle['status'] == 'solved':
355 message = "Puzzle <{}|{}> has been solved!".format(
356 puzzle['channel_url'],
359 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
360 puzzle['channel_url'],
362 slack_send_message(turb.slack_client, hunt['channel_id'], message)
364 # We need to set the channel topic if any of puzzle name, url,
365 # state, status, or solution, has changed. Let's just do that
366 # unconditionally here.
367 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
371 def edit_hunt_command(turb, body):
372 """Implementation of the `/hunt edit` command
374 As dispatched from the hunt() function.
377 channel_id = body['channel_id'][0]
378 trigger_id = body['trigger_id'][0]
380 hunt = hunt_for_channel(turb, channel_id)
383 return bot_reply("Sorry, this does not appear to be a hunt channel.")
385 return edit_hunt(turb, hunt, trigger_id)
387 def edit_hunt_button(turb, payload):
388 """Handler for the action of user pressing an edit_hunt button"""
390 hunt_id = payload['actions'][0]['action_id']
391 trigger_id = payload['trigger_id']
393 hunt = find_hunt_for_hunt_id(turb, hunt_id)
396 return bot_reply("Error: Hunt not found.")
398 return edit_hunt(turb, hunt, trigger_id)
400 actions['button']['edit_hunt'] = edit_hunt_button
402 def edit_hunt(turb, hunt, trigger_id):
403 """Common code for implementing an edit hunt dialog
405 This implementation is common whether the edit operation was invoked
406 by a button (edit_hunt_button) or a command (edit_hunt_command).
411 "private_metadata": json.dumps({
412 "hunt_id": hunt["hunt_id"],
414 "is_hunt": hunt["is_hunt"],
415 "channel_id": hunt["channel_id"],
416 "sheet_url": hunt["sheet_url"],
417 "folder_id": hunt["folder_id"],
419 "title": { "type": "plain_text", "text": "Edit Hunt" },
420 "submit": { "type": "plain_text", "text": "Save" },
422 input_block("Hunt name", "name", "Name of the hunt",
423 initial_value=hunt["name"]),
424 input_block("Hunt URL", "url", "External URL of hunt",
425 initial_value=hunt.get("url", None),
427 checkbox_block("Is this hunt active?", "Active", "active",
428 checked=(hunt.get('active', False)))
432 result = turb.slack_client.views_open(trigger_id=trigger_id,
436 submission_handlers[result['view']['id']] = edit_hunt_submission
440 def edit_hunt_submission(turb, payload, metadata):
441 """Handler for the user submitting the edit hunt modal
443 This is the modal view presented by the edit_hunt function above.
448 # First, read all the various data from the request
449 meta = json.loads(metadata)
450 hunt['hunt_id'] = meta['hunt_id']
451 hunt['SK'] = meta['SK']
452 hunt['is_hunt'] = meta['is_hunt']
453 hunt['channel_id'] = meta['channel_id']
454 hunt['sheet_url'] = meta['sheet_url']
455 hunt['folder_id'] = meta['folder_id']
457 state = payload['view']['state']['values']
458 user_id = payload['user']['id']
460 hunt['name'] = state['name']['name']['value']
461 url = state['url']['url']['value']
465 if state['active']['active']['selected_options']:
466 hunt['active'] = True
468 hunt['active'] = False
470 # Update the hunt in the database
471 turb.table.put_item(Item=hunt)
473 # Inform the hunt channel about the edit
474 edit_message = "Hunt edited by <@{}>".format(user_id)
476 section_block(text_block(edit_message)),
477 section_block(text_block("Hunt name: {}".format(hunt['name']))),
480 url = hunt.get('url', None)
483 section_block(text_block("Hunt URL: {}".format(hunt['url'])))
487 turb.slack_client, hunt['channel_id'],
488 edit_message, blocks=blocks)
492 def new_hunt_command(turb, body):
493 """Implementation of the '/hunt new' command
495 As dispatched from the hunt() function.
498 trigger_id = body['trigger_id'][0]
500 return new_hunt(turb, trigger_id)
502 def new_hunt_button(turb, payload):
503 """Handler for the action of user pressing the new_hunt button"""
505 trigger_id = payload['trigger_id']
507 return new_hunt(turb, trigger_id)
509 def new_hunt(turb, trigger_id):
510 """Common code for implementing a new hunt dialog
512 This implementation is common whether the operations was invoked
513 by a button (new_hunt_button) or a command (new_hunt_command).
518 "private_metadata": json.dumps({}),
519 "title": { "type": "plain_text", "text": "New Hunt" },
520 "submit": { "type": "plain_text", "text": "Create" },
522 input_block("Hunt name", "name", "Name of the hunt"),
523 input_block("Hunt ID", "hunt_id",
524 "Used as puzzle channel prefix "
525 + "(no spaces nor punctuation)"),
526 input_block("Hunt URL", "url", "External URL of hunt",
531 result = turb.slack_client.views_open(trigger_id=trigger_id,
534 submission_handlers[result['view']['id']] = new_hunt_submission
538 actions['button']['new_hunt'] = new_hunt_button
540 def new_hunt_submission(turb, payload, metadata):
541 """Handler for the user submitting the new hunt modal
543 This is the modal view presented to the user by the new_hunt
546 state = payload['view']['state']['values']
547 user_id = payload['user']['id']
548 name = state['name']['name']['value']
549 hunt_id = state['hunt_id']['hunt_id']['value']
550 url = state['url']['url']['value']
552 # Validate that the hunt_id contains no invalid characters
553 if not re.match(valid_id_re, hunt_id):
554 return submission_error("hunt_id",
555 "Hunt ID can only contain lowercase letters, "
556 + "numbers, and underscores")
558 # Check to see if the turbot table exists
560 exists = turb.table.table_status in ("CREATING", "UPDATING",
565 # Create the turbot table if necessary.
567 turb.table = turb.db.create_table(
570 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
571 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
573 AttributeDefinitions=[
574 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
575 {'AttributeName': 'SK', 'AttributeType': 'S'},
576 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
577 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
578 {'AttributeName': 'url', 'AttributeType': 'S'},
579 {'AttributeName': 'puzzle_id', 'AttributeType': 'S'}
581 ProvisionedThroughput={
582 'ReadCapacityUnits': 5,
583 'WriteCapacityUnits': 5
585 GlobalSecondaryIndexes=[
587 'IndexName': 'channel_id_index',
589 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
592 'ProjectionType': 'ALL'
594 'ProvisionedThroughput': {
595 'ReadCapacityUnits': 5,
596 'WriteCapacityUnits': 5
600 'IndexName': 'is_hunt_index',
602 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
605 'ProjectionType': 'ALL'
607 'ProvisionedThroughput': {
608 'ReadCapacityUnits': 5,
609 'WriteCapacityUnits': 5
613 LocalSecondaryIndexes = [
615 'IndexName': 'url_index',
617 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
618 {'AttributeName': 'url', 'KeyType': 'RANGE'},
621 'ProjectionType': 'ALL'
625 'IndexName': 'puzzle_id_index',
627 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
628 {'AttributeName': 'puzzle_id', 'KeyType': 'RANGE'},
631 'ProjectionType': 'ALL'
636 return submission_error(
638 "Still bootstrapping turbot table. Try again in a minute, please.")
640 # Create a channel for the hunt
642 response = turb.slack_client.conversations_create(name=hunt_id)
643 except SlackApiError as e:
644 return submission_error("hunt_id",
645 "Error creating Slack channel: {}"
646 .format(e.response['error']))
648 channel_id = response['channel']['id']
650 # Insert the newly-created hunt into the database
651 # (leaving it as non-active for now until the channel-created handler
652 # finishes fixing it up with a sheet and a companion table)
655 "SK": "hunt-{}".format(hunt_id),
657 "channel_id": channel_id,
663 turb.table.put_item(Item=item)
665 # Invite the initiating user to the channel
666 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
670 def view_submission(turb, payload):
671 """Handler for Slack interactive view submission
673 Specifically, those that have a payload type of 'view_submission'"""
675 view_id = payload['view']['id']
676 metadata = payload['view']['private_metadata']
678 if view_id in submission_handlers:
679 return submission_handlers[view_id](turb, payload, metadata)
681 print("Error: Unknown view ID: {}".format(view_id))
686 def rot(turb, body, args):
687 """Implementation of the /rot command
689 The args string should be as follows:
691 [count|*] String to be rotated
693 That is, the first word of the string is an optional number (or
694 the character '*'). If this is a number it indicates an amount to
695 rotate each character in the string. If the count is '*' or is not
696 present, then the string will be rotated through all possible 25
699 The result of the rotation is returned (with Slack formatting) in
700 the body of the response so that Slack will provide it as a reply
701 to the user who submitted the slash command."""
703 channel_name = body['channel_name'][0]
704 response_url = body['response_url'][0]
705 channel_id = body['channel_id'][0]
707 result = turbot.rot.rot(args)
709 if (channel_name == "directmessage"):
710 requests.post(response_url,
711 json = {"text": result},
712 headers = {"Content-type": "application/json"})
714 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
718 commands["/rot"] = rot
720 def get_table_item(turb, table_name, key, value):
721 """Get an item from the database 'table_name' with 'key' as 'value'
723 Returns a tuple of (item, table) if found and (None, None) otherwise."""
725 table = turb.db.Table(table_name)
727 response = table.get_item(Key={key: value})
729 if 'Item' in response:
730 return (response['Item'], table)
734 def db_entry_for_channel(turb, channel_id):
735 """Given a channel ID return the database item for this channel
737 If this channel is a registered hunt or puzzle channel, return the
738 corresponding row from the database for this channel. Otherwise,
741 Note: If you need to specifically ensure that the channel is a
742 puzzle or a hunt, please call puzzle_for_channel or
743 hunt_for_channel respectively.
746 response = turb.table.query(
747 IndexName = "channel_id_index",
748 KeyConditionExpression=Key("channel_id").eq(channel_id)
751 if response['Count'] == 0:
754 return response['Items'][0]
757 def puzzle_for_channel(turb, channel_id):
759 """Given a channel ID return the puzzle from the database for this channel
761 If the given channel_id is a puzzle's channel, this function
762 returns a dict filled with the attributes from the puzzle's entry
765 Otherwise, this function returns None.
768 entry = db_entry_for_channel(turb, channel_id)
770 if entry and entry['SK'].startswith('puzzle-'):
775 def hunt_for_channel(turb, channel_id):
777 """Given a channel ID return the hunt from the database for this channel
779 This works whether the original channel is a primary hunt channel,
780 or if it is one of the channels of a puzzle belonging to the hunt.
782 Returns None if channel does not belong to a hunt, otherwise a
783 dictionary with all fields from the hunt's row in the table,
784 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
787 entry = db_entry_for_channel(turb, channel_id)
789 # We're done if this channel doesn't exist in the database at all
793 # Also done if this channel is a hunt channel
794 if entry['SK'].startswith('hunt-'):
797 # Otherwise, (the channel is in the database, but is not a hunt),
798 # we expect this to be a puzzle channel instead
799 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
801 # python3.9 has a built-in removeprefix but AWS only has python3.8
802 def remove_prefix(text, prefix):
803 if text.startswith(prefix):
804 return text[len(prefix):]
807 def hunt_rounds(turb, hunt_id):
808 """Returns array of strings giving rounds that exist in the given hunt"""
810 response = turb.table.query(
811 KeyConditionExpression=(
812 Key('hunt_id').eq(hunt_id) &
813 Key('SK').begins_with('round-')
817 if response['Count'] == 0:
820 return [remove_prefix(option['SK'], 'round-')
821 for option in response['Items']]
823 def puzzle(turb, body, args):
824 """Implementation of the /puzzle command
826 The args string can be a sub-command:
828 /puzzle new: Bring up a dialog to create a new puzzle
830 /puzzle edit: Edit the puzzle for the current channel
832 Or with no argument at all:
834 /puzzle: Print details of the current puzzle (if in a puzzle channel)
838 return new_puzzle(turb, body)
841 return edit_puzzle_command(turb, body)
844 return bot_reply("Unknown syntax for `/puzzle` command. " +
845 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
846 "and `/puzzle new` to display, edit, or create " +
849 # For no arguments we print the current puzzle as a reply
850 channel_id = body['channel_id'][0]
851 response_url = body['response_url'][0]
853 puzzle = puzzle_for_channel(turb, channel_id)
856 hunt = hunt_for_channel(turb, channel_id)
859 "This is not a puzzle channel, but is a hunt channel. "
860 + "If you want to create a new puzzle for this hunt, use "
864 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
865 + "channel, so the `/puzzle` command cannot work here.")
867 blocks = puzzle_blocks(puzzle, include_rounds=True)
869 # For a meta puzzle, also display the titles and solutions for all
870 # puzzles in the same round.
871 if puzzle.get('type', 'plain') == 'meta':
872 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
874 # Drop this puzzle itself from the report
875 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
877 for round in puzzle.get('rounds', [None]):
878 answers = round_quoted_puzzles_titles_answers(round, puzzles)
880 section_block(text_block(
881 "*Feeder solutions from round {}*".format(
882 round if round else "<none>"
884 section_block(text_block(answers))
887 requests.post(response_url,
888 json = {'blocks': blocks},
889 headers = {'Content-type': 'application/json'}
894 commands["/puzzle"] = puzzle
896 def new(turb, body, args):
897 """Implementation of the `/new` command
899 This can be used to create a new hunt ("/new hunt") or a new
900 puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
901 default behavior (as it is much more common).
903 This operations are identical to the existing "/hunt new" and
904 "/puzzle new". I don't know that that redundancy is actually
905 helpful in the interface. But at least having both allows us to
906 experiment and decide which is more natural and should be kept
911 return new_hunt_command(turb, body)
913 return new_puzzle(turb, body)
915 commands["/new"] = new
917 def new_puzzle(turb, body):
918 """Implementation of the "/puzzle new" command
920 This brings up a dialog box for creating a new puzzle.
923 channel_id = body['channel_id'][0]
924 trigger_id = body['trigger_id'][0]
926 hunt = hunt_for_channel(turb, channel_id)
929 return bot_reply("Sorry, this channel doesn't appear to "
930 + "be a hunt or puzzle channel")
932 # We used puzzle (if available) to select the initial round(s)
933 puzzle = puzzle_for_channel(turb, channel_id)
934 initial_rounds = None
936 initial_rounds=puzzle.get("rounds", None)
938 round_options = hunt_rounds(turb, hunt['hunt_id'])
940 if len(round_options):
941 round_options_block = [
942 multi_select_block("Round(s)", "rounds",
943 "Existing round(s) this puzzle belongs to",
945 initial_options=initial_rounds)
948 round_options_block = []
952 "private_metadata": json.dumps({
953 "hunt_id": hunt['hunt_id'],
955 "title": {"type": "plain_text", "text": "New Puzzle"},
956 "submit": { "type": "plain_text", "text": "Create" },
958 section_block(text_block("*For {}*".format(hunt['name']))),
959 input_block("Puzzle name", "name", "Name of the puzzle"),
960 input_block("Puzzle URL", "url", "External URL of puzzle",
962 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
963 * round_options_block,
964 input_block("New round(s)", "new_rounds",
965 "New round(s) this puzzle belongs to " +
971 result = turb.slack_client.views_open(trigger_id=trigger_id,
975 submission_handlers[result['view']['id']] = new_puzzle_submission
979 def new_puzzle_submission(turb, payload, metadata):
980 """Handler for the user submitting the new puzzle modal
982 This is the modal view presented to the user by the new_puzzle
986 # First, read all the various data from the request
987 meta = json.loads(metadata)
988 hunt_id = meta['hunt_id']
990 state = payload['view']['state']['values']
992 # And start loading data into a puzzle dict
994 puzzle['hunt_id'] = hunt_id
995 puzzle['name'] = state['name']['name']['value']
996 url = state['url']['url']['value']
999 if state['meta']['meta']['selected_options']:
1000 puzzle['type'] = 'meta'
1002 puzzle['type'] = 'plain'
1003 if 'rounds' in state:
1004 rounds = [option['value'] for option in
1005 state['rounds']['rounds']['selected_options']]
1008 new_rounds = state['new_rounds']['new_rounds']['value']
1010 # Create a Slack-channel-safe puzzle_id
1011 puzzle['puzzle_id'] = puzzle_id_from_name(puzzle['name'])
1013 # Before doing anything, reject this puzzle if a puzzle already
1014 # exists with the same puzzle_id or url
1015 existing = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle['puzzle_id'])
1017 return submission_error(
1019 "Error: This name collides with an existing puzzle.")
1022 existing = find_puzzle_for_url(turb, hunt_id, url)
1024 return submission_error(
1026 "Error: A puzzle with this URL already exists.")
1028 # Add any new rounds to the database
1030 for round in new_rounds.split(','):
1031 # Drop any leading/trailing spaces from the round name
1032 round = round.strip()
1033 # Ignore any empty string
1036 rounds.append(round)
1037 turb.table.put_item(
1040 'SK': 'round-' + round
1045 puzzle['rounds'] = rounds
1047 puzzle['solution'] = set()
1048 puzzle['status'] = 'unsolved'
1050 # Create a channel for the puzzle
1051 channel_name = puzzle_channel_name(puzzle)
1054 response = turb.slack_client.conversations_create(
1056 except SlackApiError as e:
1057 return submission_error(
1059 "Error creating Slack channel {}: {}"
1060 .format(channel_name, e.response['error']))
1062 puzzle['channel_id'] = response['channel']['id']
1064 # Finally, compute the appropriate sort key
1065 puzzle["SK"] = puzzle_sort_key(puzzle)
1067 # Insert the newly-created puzzle into the database
1068 turb.table.put_item(Item=puzzle)
1072 def state(turb, body, args):
1073 """Implementation of the /state command
1075 The args string should be a brief sentence describing where things
1076 stand or what's needed."""
1078 channel_id = body['channel_id'][0]
1080 old_puzzle = puzzle_for_channel(turb, channel_id)
1084 "Sorry, the /state command only works in a puzzle channel")
1086 # Make a deep copy of the puzzle object
1087 puzzle = puzzle_copy(old_puzzle)
1089 # Update the puzzle in the database
1090 puzzle['state'] = args
1091 turb.table.put_item(Item=puzzle)
1093 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1097 commands["/state"] = state
1099 def tag(turb, body, args):
1100 """Implementation of the `/tag` command.
1102 Arg is either a tag to add (optionally prefixed with '+'), or if
1103 prefixed with '-' is a tag to remove.
1107 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1108 + "or `/tag -TAG_TO_REMOVE`.")
1110 channel_id = body['channel_id'][0]
1112 old_puzzle = puzzle_for_channel(turb, channel_id)
1116 "Sorry, the /tag command only works in a puzzle channel")
1127 # Force tag to all uppercase
1130 # Reject a tag that is not alphabetic or underscore A-Z_
1131 if not re.match(r'^[A-Z0-9_]*$', tag):
1132 return bot_reply("Sorry, tags can only contain letters, numbers, "
1133 + "and the underscore character.")
1135 if action == 'remove':
1136 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1137 return bot_reply("Nothing to do. This puzzle is not tagged "
1138 + "with the tag: {}".format(tag))
1140 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1141 return bot_reply("Nothing to do. This puzzle is already tagged "
1142 + "with the tag: {}".format(tag))
1144 # OK. Error checking is done. Let's get to work
1146 # Make a deep copy of the puzzle object
1147 puzzle = puzzle_copy(old_puzzle)
1149 if action == 'remove':
1150 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1152 if 'tags' not in puzzle:
1153 puzzle['tags'] = [tag]
1155 puzzle['tags'].append(tag)
1157 turb.table.put_item(Item=puzzle)
1159 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1163 commands["/tag"] = tag
1165 def solved(turb, body, args):
1166 """Implementation of the /solved command
1168 The args string should be a confirmed solution."""
1170 channel_id = body['channel_id'][0]
1171 user_id = body['user_id'][0]
1173 old_puzzle = puzzle_for_channel(turb, channel_id)
1176 return bot_reply("Sorry, this is not a puzzle channel.")
1180 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1182 # Make a deep copy of the puzzle object
1183 puzzle = puzzle_copy(old_puzzle)
1185 # Set the status and solution fields in the database
1186 puzzle['status'] = 'solved'
1187 puzzle['solution'].add(args)
1188 if 'state' in puzzle:
1190 turb.table.put_item(Item=puzzle)
1192 # Report the solution to the puzzle's channel
1194 turb.slack_client, channel_id,
1195 "Puzzle mark solved by <@{}>: `{}`".format(user_id, args))
1197 # Also report the solution to the hunt channel
1198 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1200 turb.slack_client, hunt['channel_id'],
1201 "Puzzle <{}|{}> has been solved!".format(
1202 puzzle['channel_url'],
1206 # And update the puzzle's description
1207 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1211 commands["/solved"] = solved
1213 def hunt(turb, body, args):
1214 """Implementation of the /hunt command
1216 The (optional) args string can be used to filter which puzzles to
1217 display. The first word can be one of 'all', 'unsolved', or
1218 'solved' and can be used to display only puzzles with the given
1219 status. If this first word is missing, this command will display
1220 only unsolved puzzles by default.
1222 Any remaining text in the args string will be interpreted as
1223 search terms. These will be split into separate terms on space
1224 characters, (though quotation marks can be used to include a space
1225 character in a term). All terms must match on a puzzle in order
1226 for that puzzle to be included. But a puzzle will be considered to
1227 match if any of the puzzle title, round title, puzzle URL, puzzle
1228 state, puzzle type, tags, or puzzle solution match. Matching will
1229 be performed without regard to case sensitivity and the search
1230 terms can include regular expression syntax.
1234 channel_id = body['channel_id'][0]
1235 response_url = body['response_url'][0]
1237 # First, farm off "/hunt new" and "/hunt edit" a separate commands
1239 return new_hunt_command(turb, body)
1242 return edit_hunt_command(turb, body)
1246 # The first word can be a puzzle status and all remaining word
1247 # (if any) are search terms. _But_, if the first word is not a
1248 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1249 # words are search terms and we default status to 'unsolved'.
1250 split_args = args.split(' ', 1)
1251 status = split_args[0]
1252 if (len(split_args) > 1):
1253 terms = split_args[1]
1254 if status not in ('unsolved', 'solved', 'all'):
1260 # Separate search terms on spaces (but allow for quotation marks
1261 # to capture spaces in a search term)
1263 terms = shlex.split(terms)
1265 hunt = hunt_for_channel(turb, channel_id)
1268 return bot_reply("Sorry, this channel doesn't appear to "
1269 + "be a hunt or puzzle channel")
1271 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1273 for block in blocks:
1274 if len(block) > 100:
1276 requests.post(response_url,
1277 json = { 'blocks': block },
1278 headers = {'Content-type': 'application/json'}
1283 commands["/hunt"] = hunt
1285 def round(turb, body, args):
1286 """Implementation of the /round command
1288 Displays puzzles in the same round(s) as the puzzle for the
1291 The (optional) args string can be used to filter which puzzles to
1292 display. The first word can be one of 'all', 'unsolved', or
1293 'solved' and can be used to display only puzzles with the given
1294 status. If this first word is missing, this command will display
1295 all puzzles in the round by default.
1297 Any remaining text in the args string will be interpreted as
1298 search terms. These will be split into separate terms on space
1299 characters, (though quotation marks can be used to include a space
1300 character in a term). All terms must match on a puzzle in order
1301 for that puzzle to be included. But a puzzle will be considered to
1302 match if any of the puzzle title, round title, puzzle URL, puzzle
1303 state, or puzzle solution match. Matching will be performed
1304 without regard to case sensitivity and the search terms can
1305 include regular expression syntax.
1308 channel_id = body['channel_id'][0]
1309 response_url = body['response_url'][0]
1311 puzzle = puzzle_for_channel(turb, channel_id)
1312 hunt = hunt_for_channel(turb, channel_id)
1317 "This is not a puzzle channel, but is a hunt channel. "
1318 + "Use /hunt if you want to see all rounds for this hunt.")
1321 "Sorry, this channel doesn't appear to be a puzzle channel "
1322 + "so the `/round` command cannot work here.")
1326 # The first word can be a puzzle status and all remaining word
1327 # (if any) are search terms. _But_, if the first word is not a
1328 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1329 # words are search terms and we default status to 'unsolved'.
1330 split_args = args.split(' ', 1)
1331 status = split_args[0]
1332 if (len(split_args) > 1):
1333 terms = split_args[1]
1334 if status not in ('unsolved', 'solved', 'all'):
1340 # Separate search terms on spaces (but allow for quotation marks
1341 # to capture spaces in a search term)
1343 terms = shlex.split(terms)
1345 blocks = hunt_blocks(turb, hunt,
1346 puzzle_status=status, search_terms=terms,
1347 limit_to_rounds=puzzle.get('rounds', [])
1350 for block in blocks:
1351 if len(block) > 100:
1353 requests.post(response_url,
1354 json = { 'blocks': block },
1355 headers = {'Content-type': 'application/json'}
1360 commands["/round"] = round
1362 def help_command(turb, body, args):
1363 """Implementation of the /help command
1365 Displays help on how to use Turbot.
1368 channel_id = body['channel_id'][0]
1369 response_url = body['response_url'][0]
1370 user_id = body['user_id'][0]
1372 # Process "/help me" first. It calls out to have_you_tried rather
1373 # than going through our help system.
1375 # Also, it reports in the current channel, (where all other help
1376 # output is reported privately to the invoking user).
1378 to_try = "In response to <@{}> asking `/help me`:\n\n{}\n".format(
1379 user_id, have_you_tried())
1381 # We'll try first to reply directly to the channel (for the benefit
1382 # of anyone else in the same channel that might be stuck too.
1384 # But if this doesn't work, (direct message or private channel),
1385 # then we can instead reply with an ephemeral message by using
1388 turb.slack_client.chat_postMessage(
1389 channel=channel_id, text=to_try)
1390 except SlackApiError:
1391 requests.post(response_url,
1392 json = {"text": to_try},
1393 headers = {"Content-type": "application/json"})
1396 help_string = turbot_help(args)
1398 requests.post(response_url,
1399 json = {"text": help_string},
1400 headers = {"Content-type": "application/json"})
1404 commands["/help"] = help_command