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 puzzle_update_channel_and_sheet,
19 from turbot.round import round_quoted_puzzles_titles_answers
20 from turbot.help import turbot_help
21 from turbot.have_you_tried import have_you_tried
28 from botocore.exceptions import ClientError
29 from boto3.dynamodb.conditions import Key
30 from turbot.slack import slack_send_message
34 actions['button'] = {}
36 submission_handlers = {}
38 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
40 # Note: This restriction not only allows for hunt and puzzle ID values to
41 # be used as Slack channel names, but it also allows for '-' as a valid
42 # separator between a hunt and a puzzle ID (for example in the puzzle
43 # edit dialog where a single attribute must capture both values).
44 valid_id_re = r'^[_a-z0-9]+$'
46 lambda_ok = {'statusCode': 200}
48 def bot_reply(message):
49 """Construct a return value suitable for a bot reply
51 This is suitable as a way to give an error back to the user who
52 initiated a slash command, for example."""
59 def submission_error(field, error):
60 """Construct an error suitable for returning for an invalid submission.
62 Returning this value will prevent a submission and alert the user that
63 the given field is invalid because of the given error."""
65 print("Rejecting invalid modal submission: {}".format(error))
70 "Content-Type": "application/json"
73 "response_action": "errors",
80 def multi_static_select(turb, payload):
81 """Handler for the action of user entering a multi-select value"""
85 actions['multi_static_select'] = {"*": multi_static_select}
87 def edit(turb, body, args):
89 """Implementation of the `/edit` command
91 This can be used as `/edit` (with no arguments) in either a hunt
92 or a puzzle channel to edit that hunt or puzzle. It can also be
93 called explicitly as `/edit hunt` to edit a hunt even from a
96 In any case, the operation is identical to `/hunt edit` or
100 # If we have an explicit argument, do what it says to do
102 return edit_hunt_command(turb, body)
105 return edit_puzzle_command(turb, body)
107 # Any other argument string is an error
109 return bot_reply("Error: Unexpected argument: {}\n".format(args) +
110 "Usage: `/edit puzzle`, `/edit hunt`, or " +
111 "`/edit` (to choose based on channel)"
114 # No explicit argument, so select what to edit based on the current channel
115 channel_id = body['channel_id'][0]
116 trigger_id = body['trigger_id'][0]
118 puzzle = puzzle_for_channel(turb, channel_id)
120 return edit_puzzle(turb, puzzle, trigger_id)
122 hunt = hunt_for_channel(turb, channel_id)
124 return edit_hunt(turb, hunt, trigger_id)
126 return bot_reply("Sorry, `/edit` only works in a hunt or puzzle channel.")
128 commands["/edit"] = edit
131 def edit_puzzle_command(turb, body):
132 """Implementation of the `/puzzle edit` command
134 As dispatched from the puzzle() function.
137 channel_id = body['channel_id'][0]
138 trigger_id = body['trigger_id'][0]
140 puzzle = puzzle_for_channel(turb, channel_id)
143 return bot_reply("Sorry, this does not appear to be a puzzle channel.")
145 return edit_puzzle(turb, puzzle, trigger_id)
147 def edit_puzzle_button(turb, payload):
148 """Handler for the action of user pressing an edit_puzzle button"""
150 action_id = payload['actions'][0]['action_id']
151 trigger_id = payload['trigger_id']
153 (hunt_id, sort_key) = action_id.split('-', 1)
155 puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
158 return bot_reply("Error: Puzzle not found.")
160 return edit_puzzle(turb, puzzle, trigger_id)
162 actions['button']['edit_puzzle'] = edit_puzzle_button
164 def edit_puzzle(turb, puzzle, trigger_id):
165 """Common code for implementing an edit puzzle dialog
167 This implementation is common whether the edit operation was invoked
168 by a button (edit_puzzle_button) or a command (edit_puzzle_command).
171 round_options = hunt_rounds(turb, puzzle['hunt_id'])
173 if len(round_options):
174 round_options_block = [
175 multi_select_block("Round(s)", "rounds",
176 "Existing round(s) this puzzle belongs to",
178 initial_options=puzzle.get("rounds", None)),
181 round_options_block = []
184 if puzzle.get("status", "unsolved") == solved:
188 solution_list = puzzle.get("solution", [])
190 solution_str = ", ".join(solution_list)
194 "private_metadata": json.dumps({
195 "hunt_id": puzzle['hunt_id'],
197 "puzzle_id": puzzle['puzzle_id'],
198 "channel_id": puzzle["channel_id"],
199 "channel_url": puzzle["channel_url"],
200 "sheet_url": puzzle["sheet_url"],
202 "title": {"type": "plain_text", "text": "Edit Puzzle"},
203 "submit": { "type": "plain_text", "text": "Save" },
205 input_block("Puzzle name", "name", "Name of the puzzle",
206 initial_value=puzzle["name"]),
207 input_block("Puzzle URL", "url", "External URL of puzzle",
208 initial_value=puzzle.get("url", None),
210 checkbox_block("Is this a meta puzzle?", "Meta", "meta",
211 checked=(puzzle.get('type', 'plain') == 'meta')),
212 * round_options_block,
213 input_block("New round(s)", "new_rounds",
214 "New round(s) this puzzle belongs to " +
217 input_block("State", "state",
218 "State of this puzzle (partial progress, next steps)",
219 initial_value=puzzle.get("state", None),
222 "Puzzle status", "Solved", "solved",
223 checked=(puzzle.get('status', 'unsolved') == 'solved')),
224 input_block("Solution", "solution",
225 "Solution(s) (comma-separated if multiple)",
226 initial_value=solution_str,
231 result = turb.slack_client.views_open(trigger_id=trigger_id,
235 submission_handlers[result['view']['id']] = edit_puzzle_submission
239 def edit_puzzle_submission(turb, payload, metadata):
240 """Handler for the user submitting the edit puzzle modal
242 This is the modal view presented to the user by the edit_puzzle
248 # First, read all the various data from the request
249 meta = json.loads(metadata)
250 puzzle['hunt_id'] = meta['hunt_id']
251 puzzle['SK'] = meta['SK']
252 puzzle['puzzle_id'] = meta['puzzle_id']
253 puzzle['channel_id'] = meta['channel_id']
254 puzzle['channel_url'] = meta['channel_url']
255 puzzle['sheet_url'] = meta['sheet_url']
257 state = payload['view']['state']['values']
258 user_id = payload['user']['id']
260 puzzle['name'] = state['name']['name']['value']
261 url = state['url']['url']['value']
264 if state['meta']['meta']['selected_options']:
265 puzzle['type'] = 'meta'
267 puzzle['type'] = 'plain'
268 rounds = [option['value'] for option in
269 state['rounds']['rounds']['selected_options']]
271 puzzle['rounds'] = rounds
272 new_rounds = state['new_rounds']['new_rounds']['value']
273 puzzle_state = state['state']['state']['value']
275 puzzle['state'] = puzzle_state
276 if state['solved']['solved']['selected_options']:
277 puzzle['status'] = 'solved'
279 puzzle['status'] = 'unsolved'
280 puzzle['solution'] = []
281 solution = state['solution']['solution']['value']
283 puzzle['solution'] = [
284 sol.strip() for sol in solution.split(',')
287 # Verify that there's a solution if the puzzle is mark solved
288 if puzzle['status'] == 'solved' and not puzzle['solution']:
289 return submission_error("solution",
290 "A solved puzzle requires a solution.")
292 if puzzle['status'] == 'unsolved' and puzzle['solution']:
293 return submission_error("solution",
294 "An unsolved puzzle should have no solution.")
296 # Add any new rounds to the database
298 if 'rounds' not in puzzle:
299 puzzle['rounds'] = []
300 for round in new_rounds.split(','):
301 # Drop any leading/trailing spaces from the round name
302 round = round.strip()
303 # Ignore any empty string
306 puzzle['rounds'].append(round)
309 'hunt_id': puzzle['hunt_id'],
310 'SK': 'round-' + round
314 # Get old puzzle from the database (to determine what's changed)
315 old_puzzle = find_puzzle_for_sort_key(turb,
319 # If we are changing puzzle type (meta -> plain or plain -> meta)
320 # the the sort key has to change, so compute the new one and delete
321 # the old item from the database.
323 # XXX: We should really be using a transaction here to combine the
324 # delete_item and the put_item into a single transaction, but
325 # the boto interface is annoying in that transactions are only on
326 # the "Client" object which has a totally different interface than
327 # the "Table" object I've been using so I haven't figured out how
330 if puzzle['type'] != old_puzzle.get('type', 'plain'):
331 puzzle['SK'] = puzzle_sort_key(puzzle)
332 turb.table.delete_item(Key={
333 'hunt_id': old_puzzle['hunt_id'],
334 'SK': old_puzzle['SK']
337 # Update the puzzle in the database
338 turb.table.put_item(Item=puzzle)
340 # Inform the puzzle channel about the edit
341 edit_message = "Puzzle edited by <@{}>".format(user_id)
342 blocks = ([section_block(text_block(edit_message+":\n"))] +
343 puzzle_blocks(puzzle, include_rounds=True))
345 turb.slack_client, puzzle['channel_id'],
346 edit_message, blocks=blocks)
348 # Also inform the hunt if the puzzle's solved status changed
349 if puzzle['status'] != old_puzzle['status']:
350 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
351 if puzzle['status'] == 'solved':
352 message = "Puzzle <{}|{}> has been solved!".format(
353 puzzle['channel_url'],
356 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
357 puzzle['channel_url'],
359 slack_send_message(turb.slack_client, hunt['channel_id'], message)
361 # We need to set the channel topic if any of puzzle name, url,
362 # state, status, or solution, has changed. Let's just do that
363 # unconditionally here.
364 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
368 def edit_hunt_command(turb, body):
369 """Implementation of the `/hunt edit` command
371 As dispatched from the hunt() function.
374 channel_id = body['channel_id'][0]
375 trigger_id = body['trigger_id'][0]
377 hunt = hunt_for_channel(turb, channel_id)
380 return bot_reply("Sorry, this does not appear to be a hunt channel.")
382 return edit_hunt(turb, hunt, trigger_id)
384 def edit_hunt_button(turb, payload):
385 """Handler for the action of user pressing an edit_hunt button"""
387 hunt_id = payload['actions'][0]['action_id']
388 trigger_id = payload['trigger_id']
390 hunt = find_hunt_for_hunt_id(turb, hunt_id)
393 return bot_reply("Error: Hunt not found.")
395 return edit_hunt(turb, hunt, trigger_id)
397 actions['button']['edit_hunt'] = edit_hunt_button
399 def edit_hunt(turb, hunt, trigger_id):
400 """Common code for implementing an edit hunt dialog
402 This implementation is common whether the edit operation was invoked
403 by a button (edit_hunt_button) or a command (edit_hunt_command).
408 "private_metadata": json.dumps({
409 "hunt_id": hunt["hunt_id"],
411 "is_hunt": hunt["is_hunt"],
412 "channel_id": hunt["channel_id"],
413 "sheet_url": hunt["sheet_url"],
414 "folder_id": hunt["folder_id"],
416 "title": { "type": "plain_text", "text": "Edit Hunt" },
417 "submit": { "type": "plain_text", "text": "Save" },
419 input_block("Hunt name", "name", "Name of the hunt",
420 initial_value=hunt["name"]),
421 input_block("Hunt URL", "url", "External URL of hunt",
422 initial_value=hunt.get("url", None),
424 checkbox_block("Is this hunt active?", "Active", "active",
425 checked=(hunt.get('active', False)))
429 result = turb.slack_client.views_open(trigger_id=trigger_id,
433 submission_handlers[result['view']['id']] = edit_hunt_submission
437 def edit_hunt_submission(turb, payload, metadata):
438 """Handler for the user submitting the edit hunt modal
440 This is the modal view presented by the edit_hunt function above.
445 # First, read all the various data from the request
446 meta = json.loads(metadata)
447 hunt['hunt_id'] = meta['hunt_id']
448 hunt['SK'] = meta['SK']
449 hunt['is_hunt'] = meta['is_hunt']
450 hunt['channel_id'] = meta['channel_id']
451 hunt['sheet_url'] = meta['sheet_url']
452 hunt['folder_id'] = meta['folder_id']
454 state = payload['view']['state']['values']
455 user_id = payload['user']['id']
457 hunt['name'] = state['name']['name']['value']
458 url = state['url']['url']['value']
462 if state['active']['active']['selected_options']:
463 hunt['active'] = True
465 hunt['active'] = False
467 # Update the hunt in the database
468 turb.table.put_item(Item=hunt)
470 # Inform the hunt channel about the edit
471 edit_message = "Hunt edited by <@{}>".format(user_id)
473 section_block(text_block(edit_message)),
474 section_block(text_block("Hunt name: {}".format(hunt['name']))),
477 url = hunt.get('url', None)
480 section_block(text_block("Hunt URL: {}".format(hunt['url'])))
484 turb.slack_client, hunt['channel_id'],
485 edit_message, blocks=blocks)
489 def new_hunt_command(turb, body):
490 """Implementation of the '/hunt new' command
492 As dispatched from the hunt() function.
495 trigger_id = body['trigger_id'][0]
497 return new_hunt(turb, trigger_id)
499 def new_hunt_button(turb, payload):
500 """Handler for the action of user pressing the new_hunt button"""
502 trigger_id = payload['trigger_id']
504 return new_hunt(turb, trigger_id)
506 def new_hunt(turb, trigger_id):
507 """Common code for implementing a new hunt dialog
509 This implementation is common whether the operations was invoked
510 by a button (new_hunt_button) or a command (new_hunt_command).
515 "private_metadata": json.dumps({}),
516 "title": { "type": "plain_text", "text": "New Hunt" },
517 "submit": { "type": "plain_text", "text": "Create" },
519 input_block("Hunt name", "name", "Name of the hunt"),
520 input_block("Hunt ID", "hunt_id",
521 "Used as puzzle channel prefix "
522 + "(no spaces nor punctuation)"),
523 input_block("Hunt URL", "url", "External URL of hunt",
528 result = turb.slack_client.views_open(trigger_id=trigger_id,
531 submission_handlers[result['view']['id']] = new_hunt_submission
535 actions['button']['new_hunt'] = new_hunt
537 def new_hunt_submission(turb, payload, metadata):
538 """Handler for the user submitting the new hunt modal
540 This is the modal view presented to the user by the new_hunt
543 state = payload['view']['state']['values']
544 user_id = payload['user']['id']
545 name = state['name']['name']['value']
546 hunt_id = state['hunt_id']['hunt_id']['value']
547 url = state['url']['url']['value']
549 # Validate that the hunt_id contains no invalid characters
550 if not re.match(valid_id_re, hunt_id):
551 return submission_error("hunt_id",
552 "Hunt ID can only contain lowercase letters, "
553 + "numbers, and underscores")
555 # Check to see if the turbot table exists
557 exists = turb.table.table_status in ("CREATING", "UPDATING",
562 # Create the turbot table if necessary.
564 turb.table = turb.db.create_table(
567 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
568 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
570 AttributeDefinitions=[
571 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
572 {'AttributeName': 'SK', 'AttributeType': 'S'},
573 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
574 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
575 {'AttributeName': 'url', 'AttributeType': 'S'},
576 {'AttributeName': 'puzzle_id', 'AttributeType': 'S'}
578 ProvisionedThroughput={
579 'ReadCapacityUnits': 5,
580 'WriteCapacityUnits': 5
582 GlobalSecondaryIndexes=[
584 'IndexName': 'channel_id_index',
586 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
589 'ProjectionType': 'ALL'
591 'ProvisionedThroughput': {
592 'ReadCapacityUnits': 5,
593 'WriteCapacityUnits': 5
597 'IndexName': 'is_hunt_index',
599 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
602 'ProjectionType': 'ALL'
604 'ProvisionedThroughput': {
605 'ReadCapacityUnits': 5,
606 'WriteCapacityUnits': 5
610 LocalSecondaryIndexes = [
612 'IndexName': 'url_index',
614 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
615 {'AttributeName': 'url', 'KeyType': 'RANGE'},
618 'ProjectionType': 'ALL'
622 'IndexName': 'puzzle_id_index',
624 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
625 {'AttributeName': 'puzzle_id', 'KeyType': 'RANGE'},
628 'ProjectionType': 'ALL'
633 return submission_error(
635 "Still bootstrapping turbot table. Try again in a minute, please.")
637 # Create a channel for the hunt
639 response = turb.slack_client.conversations_create(name=hunt_id)
640 except SlackApiError as e:
641 return submission_error("hunt_id",
642 "Error creating Slack channel: {}"
643 .format(e.response['error']))
645 channel_id = response['channel']['id']
647 # Insert the newly-created hunt into the database
648 # (leaving it as non-active for now until the channel-created handler
649 # finishes fixing it up with a sheet and a companion table)
652 "SK": "hunt-{}".format(hunt_id),
654 "channel_id": channel_id,
660 turb.table.put_item(Item=item)
662 # Invite the initiating user to the channel
663 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
667 def view_submission(turb, payload):
668 """Handler for Slack interactive view submission
670 Specifically, those that have a payload type of 'view_submission'"""
672 view_id = payload['view']['id']
673 metadata = payload['view']['private_metadata']
675 if view_id in submission_handlers:
676 return submission_handlers[view_id](turb, payload, metadata)
678 print("Error: Unknown view ID: {}".format(view_id))
683 def rot(turb, body, args):
684 """Implementation of the /rot command
686 The args string should be as follows:
688 [count|*] String to be rotated
690 That is, the first word of the string is an optional number (or
691 the character '*'). If this is a number it indicates an amount to
692 rotate each character in the string. If the count is '*' or is not
693 present, then the string will be rotated through all possible 25
696 The result of the rotation is returned (with Slack formatting) in
697 the body of the response so that Slack will provide it as a reply
698 to the user who submitted the slash command."""
700 channel_name = body['channel_name'][0]
701 response_url = body['response_url'][0]
702 channel_id = body['channel_id'][0]
704 result = turbot.rot.rot(args)
706 if (channel_name == "directmessage"):
707 requests.post(response_url,
708 json = {"text": result},
709 headers = {"Content-type": "application/json"})
711 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
715 commands["/rot"] = rot
717 def get_table_item(turb, table_name, key, value):
718 """Get an item from the database 'table_name' with 'key' as 'value'
720 Returns a tuple of (item, table) if found and (None, None) otherwise."""
722 table = turb.db.Table(table_name)
724 response = table.get_item(Key={key: value})
726 if 'Item' in response:
727 return (response['Item'], table)
731 def db_entry_for_channel(turb, channel_id):
732 """Given a channel ID return the database item for this channel
734 If this channel is a registered hunt or puzzle channel, return the
735 corresponding row from the database for this channel. Otherwise,
738 Note: If you need to specifically ensure that the channel is a
739 puzzle or a hunt, please call puzzle_for_channel or
740 hunt_for_channel respectively.
743 response = turb.table.query(
744 IndexName = "channel_id_index",
745 KeyConditionExpression=Key("channel_id").eq(channel_id)
748 if response['Count'] == 0:
751 return response['Items'][0]
754 def puzzle_for_channel(turb, channel_id):
756 """Given a channel ID return the puzzle from the database for this channel
758 If the given channel_id is a puzzle's channel, this function
759 returns a dict filled with the attributes from the puzzle's entry
762 Otherwise, this function returns None.
765 entry = db_entry_for_channel(turb, channel_id)
767 if entry and entry['SK'].startswith('puzzle-'):
772 def hunt_for_channel(turb, channel_id):
774 """Given a channel ID return the hunt from the database for this channel
776 This works whether the original channel is a primary hunt channel,
777 or if it is one of the channels of a puzzle belonging to the hunt.
779 Returns None if channel does not belong to a hunt, otherwise a
780 dictionary with all fields from the hunt's row in the table,
781 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
784 entry = db_entry_for_channel(turb, channel_id)
786 # We're done if this channel doesn't exist in the database at all
790 # Also done if this channel is a hunt channel
791 if entry['SK'].startswith('hunt-'):
794 # Otherwise, (the channel is in the database, but is not a hunt),
795 # we expect this to be a puzzle channel instead
796 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
798 # python3.9 has a built-in removeprefix but AWS only has python3.8
799 def remove_prefix(text, prefix):
800 if text.startswith(prefix):
801 return text[len(prefix):]
804 def hunt_rounds(turb, hunt_id):
805 """Returns array of strings giving rounds that exist in the given hunt"""
807 response = turb.table.query(
808 KeyConditionExpression=(
809 Key('hunt_id').eq(hunt_id) &
810 Key('SK').begins_with('round-')
814 if response['Count'] == 0:
817 return [remove_prefix(option['SK'], 'round-')
818 for option in response['Items']]
820 def puzzle(turb, body, args):
821 """Implementation of the /puzzle command
823 The args string can be a sub-command:
825 /puzzle new: Bring up a dialog to create a new puzzle
827 /puzzle edit: Edit the puzzle for the current channel
829 Or with no argument at all:
831 /puzzle: Print details of the current puzzle (if in a puzzle channel)
835 return new_puzzle(turb, body)
838 return edit_puzzle_command(turb, body)
841 return bot_reply("Unknown syntax for `/puzzle` command. " +
842 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
843 "and `/puzzle new` to display, edit, or create " +
846 # For no arguments we print the current puzzle as a reply
847 channel_id = body['channel_id'][0]
848 response_url = body['response_url'][0]
850 puzzle = puzzle_for_channel(turb, channel_id)
853 hunt = hunt_for_channel(turb, channel_id)
856 "This is not a puzzle channel, but is a hunt channel. "
857 + "If you want to create a new puzzle for this hunt, use "
861 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
862 + "channel, so the `/puzzle` command cannot work here.")
864 blocks = puzzle_blocks(puzzle, include_rounds=True)
866 # For a meta puzzle, also display the titles and solutions for all
867 # puzzles in the same round.
868 if puzzle.get('type', 'plain') == 'meta':
869 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
871 # Drop this puzzle itself from the report
872 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
874 for round in puzzle.get('rounds', [None]):
875 answers = round_quoted_puzzles_titles_answers(round, puzzles)
877 section_block(text_block(
878 "*Feeder solutions from round {}*".format(
879 round if round else "<none>"
881 section_block(text_block(answers))
884 requests.post(response_url,
885 json = {'blocks': blocks},
886 headers = {'Content-type': 'application/json'}
891 commands["/puzzle"] = puzzle
893 def new(turb, body, args):
894 """Implementation of the `/new` command
896 This can be used to create a new hunt ("/new hunt") or a new
897 puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
898 default behavior (as it is much more common).
900 This operations are identical to the existing "/hunt new" and
901 "/puzzle new". I don't know that that redundancy is actually
902 helpful in the interface. But at least having both allows us to
903 experiment and decide which is more natural and should be kept
908 return new_hunt_command(turb, body)
910 return new_puzzle(turb, body)
912 commands["/new"] = new
914 def new_puzzle(turb, body):
915 """Implementation of the "/puzzle new" command
917 This brings up a dialog box for creating a new puzzle.
920 channel_id = body['channel_id'][0]
921 trigger_id = body['trigger_id'][0]
923 hunt = hunt_for_channel(turb, channel_id)
926 return bot_reply("Sorry, this channel doesn't appear to "
927 + "be a hunt or puzzle channel")
929 round_options = hunt_rounds(turb, hunt['hunt_id'])
931 if len(round_options):
932 round_options_block = [
933 multi_select_block("Round(s)", "rounds",
934 "Existing round(s) this puzzle belongs to",
938 round_options_block = []
942 "private_metadata": json.dumps({
943 "hunt_id": hunt['hunt_id'],
945 "title": {"type": "plain_text", "text": "New Puzzle"},
946 "submit": { "type": "plain_text", "text": "Create" },
948 section_block(text_block("*For {}*".format(hunt['name']))),
949 input_block("Puzzle name", "name", "Name of the puzzle"),
950 input_block("Puzzle URL", "url", "External URL of puzzle",
952 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
953 * round_options_block,
954 input_block("New round(s)", "new_rounds",
955 "New round(s) this puzzle belongs to " +
961 result = turb.slack_client.views_open(trigger_id=trigger_id,
965 submission_handlers[result['view']['id']] = new_puzzle_submission
969 def new_puzzle_submission(turb, payload, metadata):
970 """Handler for the user submitting the new puzzle modal
972 This is the modal view presented to the user by the new_puzzle
976 # First, read all the various data from the request
977 meta = json.loads(metadata)
978 hunt_id = meta['hunt_id']
980 state = payload['view']['state']['values']
981 name = state['name']['name']['value']
982 url = state['url']['url']['value']
983 if state['meta']['meta']['selected_options']:
986 puzzle_type = 'plain'
987 if 'rounds' in state:
988 rounds = [option['value'] for option in
989 state['rounds']['rounds']['selected_options']]
992 new_rounds = state['new_rounds']['new_rounds']['value']
994 # Before doing anything, reject this puzzle if a puzzle already
995 # exists with the same URL.
997 existing = find_puzzle_for_url(turb, hunt_id, url)
999 return submission_error(
1001 "Error: A puzzle with this URL already exists.")
1003 # Create a Slack-channel-safe puzzle_id
1004 puzzle_id = puzzle_id_from_name(name)
1006 # Create a channel for the puzzle
1007 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
1010 response = turb.slack_client.conversations_create(
1011 name=hunt_dash_channel)
1012 except SlackApiError as e:
1013 return submission_error(
1015 "Error creating Slack channel {}: {}"
1016 .format(hunt_dash_channel, e.response['error']))
1018 channel_id = response['channel']['id']
1020 # Add any new rounds to the database
1022 for round in new_rounds.split(','):
1023 # Drop any leading/trailing spaces from the round name
1024 round = round.strip()
1025 # Ignore any empty string
1028 rounds.append(round)
1029 turb.table.put_item(
1032 'SK': 'round-' + round
1036 # Construct a puzzle dict
1039 "puzzle_id": puzzle_id,
1040 "channel_id": channel_id,
1042 "status": 'unsolved',
1049 puzzle['rounds'] = rounds
1051 # Finally, compute the appropriate sort key
1052 puzzle["SK"] = puzzle_sort_key(puzzle)
1054 # Insert the newly-created puzzle into the database
1055 turb.table.put_item(Item=puzzle)
1059 def state(turb, body, args):
1060 """Implementation of the /state command
1062 The args string should be a brief sentence describing where things
1063 stand or what's needed."""
1065 channel_id = body['channel_id'][0]
1067 old_puzzle = puzzle_for_channel(turb, channel_id)
1071 "Sorry, the /state command only works in a puzzle channel")
1073 # Make a deep copy of the puzzle object
1074 puzzle = puzzle_copy(old_puzzle)
1076 # Update the puzzle in the database
1077 puzzle['state'] = args
1078 turb.table.put_item(Item=puzzle)
1080 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1084 commands["/state"] = state
1086 def tag(turb, body, args):
1087 """Implementation of the `/tag` command.
1089 Arg is either a tag to add (optionally prefixed with '+'), or if
1090 prefixed with '-' is a tag to remove.
1094 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1095 + "or `/tag -TAG_TO_REMOVE`.")
1097 channel_id = body['channel_id'][0]
1099 old_puzzle = puzzle_for_channel(turb, channel_id)
1103 "Sorry, the /tag command only works in a puzzle channel")
1114 # Force tag to all uppercase
1117 # Reject a tag that is not alphabetic or underscore A-Z_
1118 if not re.match(r'^[A-Z0-9_]*$', tag):
1119 return bot_reply("Sorry, tags can only contain letters, numbers, "
1120 + "and the underscore character.")
1122 if action == 'remove':
1123 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1124 return bot_reply("Nothing to do. This puzzle is not tagged "
1125 + "with the tag: {}".format(tag))
1127 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1128 return bot_reply("Nothing to do. This puzzle is already tagged "
1129 + "with the tag: {}".format(tag))
1131 # OK. Error checking is done. Let's get to work
1133 # Make a deep copy of the puzzle object
1134 puzzle = puzzle_copy(old_puzzle)
1136 if action == 'remove':
1137 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1139 if 'tags' not in puzzle:
1140 puzzle['tags'] = [tag]
1142 puzzle['tags'].append(tag)
1144 turb.table.put_item(Item=puzzle)
1146 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1150 commands["/tag"] = tag
1152 def solved(turb, body, args):
1153 """Implementation of the /solved command
1155 The args string should be a confirmed solution."""
1157 channel_id = body['channel_id'][0]
1158 user_id = body['user_id'][0]
1160 old_puzzle = puzzle_for_channel(turb, channel_id)
1163 return bot_reply("Sorry, this is not a puzzle channel.")
1167 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1169 # Make a deep copy of the puzzle object
1170 puzzle = puzzle_copy(old_puzzle)
1172 # Set the status and solution fields in the database
1173 puzzle['status'] = 'solved'
1174 puzzle['solution'].append(args)
1175 if 'state' in puzzle:
1177 turb.table.put_item(Item=puzzle)
1179 # Report the solution to the puzzle's channel
1181 turb.slack_client, channel_id,
1182 "Puzzle mark solved by <@{}>: `{}`".format(user_id, args))
1184 # Also report the solution to the hunt channel
1185 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1187 turb.slack_client, hunt['channel_id'],
1188 "Puzzle <{}|{}> has been solved!".format(
1189 puzzle['channel_url'],
1193 # And update the puzzle's description
1194 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1198 commands["/solved"] = solved
1200 def hunt(turb, body, args):
1201 """Implementation of the /hunt command
1203 The (optional) args string can be used to filter which puzzles to
1204 display. The first word can be one of 'all', 'unsolved', or
1205 'solved' and can be used to display only puzzles with the given
1206 status. If this first word is missing, this command will display
1207 only unsolved puzzles by default.
1209 Any remaining text in the args string will be interpreted as
1210 search terms. These will be split into separate terms on space
1211 characters, (though quotation marks can be used to include a space
1212 character in a term). All terms must match on a puzzle in order
1213 for that puzzle to be included. But a puzzle will be considered to
1214 match if any of the puzzle title, round title, puzzle URL, puzzle
1215 state, puzzle type, tags, or puzzle solution match. Matching will
1216 be performed without regard to case sensitivity and the search
1217 terms can include regular expression syntax.
1221 channel_id = body['channel_id'][0]
1222 response_url = body['response_url'][0]
1224 # First, farm off "/hunt new" and "/hunt edit" a separate commands
1226 return new_hunt_command(turb, body)
1229 return edit_hunt_command(turb, body)
1233 # The first word can be a puzzle status and all remaining word
1234 # (if any) are search terms. _But_, if the first word is not a
1235 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1236 # words are search terms and we default status to 'unsolved'.
1237 split_args = args.split(' ', 1)
1238 status = split_args[0]
1239 if (len(split_args) > 1):
1240 terms = split_args[1]
1241 if status not in ('unsolved', 'solved', 'all'):
1247 # Separate search terms on spaces (but allow for quotation marks
1248 # to capture spaces in a search term)
1250 terms = shlex.split(terms)
1252 hunt = hunt_for_channel(turb, channel_id)
1255 return bot_reply("Sorry, this channel doesn't appear to "
1256 + "be a hunt or puzzle channel")
1258 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1260 requests.post(response_url,
1261 json = { 'blocks': blocks },
1262 headers = {'Content-type': 'application/json'}
1267 commands["/hunt"] = hunt
1269 def round(turb, body, args):
1270 """Implementation of the /round command
1272 Displays puzzles in the same round(s) as the puzzle for the
1275 The (optional) args string can be used to filter which puzzles to
1276 display. The first word can be one of 'all', 'unsolved', or
1277 'solved' and can be used to display only puzzles with the given
1278 status. If this first word is missing, this command will display
1279 all puzzles in the round by default.
1281 Any remaining text in the args string will be interpreted as
1282 search terms. These will be split into separate terms on space
1283 characters, (though quotation marks can be used to include a space
1284 character in a term). All terms must match on a puzzle in order
1285 for that puzzle to be included. But a puzzle will be considered to
1286 match if any of the puzzle title, round title, puzzle URL, puzzle
1287 state, or puzzle solution match. Matching will be performed
1288 without regard to case sensitivity and the search terms can
1289 include regular expression syntax.
1292 channel_id = body['channel_id'][0]
1293 response_url = body['response_url'][0]
1295 puzzle = puzzle_for_channel(turb, channel_id)
1296 hunt = hunt_for_channel(turb, channel_id)
1301 "This is not a puzzle channel, but is a hunt channel. "
1302 + "Use /hunt if you want to see all rounds for this hunt.")
1305 "Sorry, this channel doesn't appear to be a puzzle channel "
1306 + "so the `/round` command cannot work here.")
1310 # The first word can be a puzzle status and all remaining word
1311 # (if any) are search terms. _But_, if the first word is not a
1312 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1313 # words are search terms and we default status to 'unsolved'.
1314 split_args = args.split(' ', 1)
1315 status = split_args[0]
1316 if (len(split_args) > 1):
1317 terms = split_args[1]
1318 if status not in ('unsolved', 'solved', 'all'):
1324 # Separate search terms on spaces (but allow for quotation marks
1325 # to capture spaces in a search term)
1327 terms = shlex.split(terms)
1329 blocks = hunt_blocks(turb, hunt,
1330 puzzle_status=status, search_terms=terms,
1331 limit_to_rounds=puzzle.get('rounds', [])
1334 requests.post(response_url,
1335 json = { 'blocks': blocks },
1336 headers = {'Content-type': 'application/json'}
1341 commands["/round"] = round
1343 def help_command(turb, body, args):
1344 """Implementation of the /help command
1346 Displays help on how to use Turbot.
1349 channel_id = body['channel_id'][0]
1350 response_url = body['response_url'][0]
1351 user_id = body['user_id'][0]
1353 # Process "/help me" first. It calls out to have_you_tried rather
1354 # than going through our help system.
1356 # Also, it reports in the current channel, (where all other help
1357 # output is reported privately to the invoking user).
1359 to_try = "In response to <@{}> asking `/help me`:\n\n{}\n".format(
1360 user_id, have_you_tried())
1362 # We'll try first to reply directly to the channel (for the benefit
1363 # of anyone else in the same channel that might be stuck too.
1365 # But if this doesn't work, (direct message or private channel),
1366 # then we can instead reply with an ephemeral message by using
1369 turb.slack_client.chat_postMessage(
1370 channel=channel_id, text=to_try)
1371 except SlackApiError:
1372 requests.post(response_url,
1373 json = {"text": to_try},
1374 headers = {"Content-type": "application/json"})
1377 help_string = turbot_help(args)
1379 requests.post(response_url,
1380 json = {"text": help_string},
1381 headers = {"Content-type": "application/json"})
1385 commands["/help"] = help_command