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'}
577 ProvisionedThroughput={
578 'ReadCapacityUnits': 5,
579 'WriteCapacityUnits': 5
581 GlobalSecondaryIndexes=[
583 'IndexName': 'channel_id_index',
585 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
588 'ProjectionType': 'ALL'
590 'ProvisionedThroughput': {
591 'ReadCapacityUnits': 5,
592 'WriteCapacityUnits': 5
596 'IndexName': 'is_hunt_index',
598 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
601 'ProjectionType': 'ALL'
603 'ProvisionedThroughput': {
604 'ReadCapacityUnits': 5,
605 'WriteCapacityUnits': 5
609 LocalSecondaryIndexes = [
611 'IndexName': 'url_index',
613 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
614 {'AttributeName': 'url', 'KeyType': 'RANGE'},
617 'ProjectionType': 'ALL'
622 return submission_error(
624 "Still bootstrapping turbot table. Try again in a minute, please.")
626 # Create a channel for the hunt
628 response = turb.slack_client.conversations_create(name=hunt_id)
629 except SlackApiError as e:
630 return submission_error("hunt_id",
631 "Error creating Slack channel: {}"
632 .format(e.response['error']))
634 channel_id = response['channel']['id']
636 # Insert the newly-created hunt into the database
637 # (leaving it as non-active for now until the channel-created handler
638 # finishes fixing it up with a sheet and a companion table)
641 "SK": "hunt-{}".format(hunt_id),
643 "channel_id": channel_id,
649 turb.table.put_item(Item=item)
651 # Invite the initiating user to the channel
652 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
656 def view_submission(turb, payload):
657 """Handler for Slack interactive view submission
659 Specifically, those that have a payload type of 'view_submission'"""
661 view_id = payload['view']['id']
662 metadata = payload['view']['private_metadata']
664 if view_id in submission_handlers:
665 return submission_handlers[view_id](turb, payload, metadata)
667 print("Error: Unknown view ID: {}".format(view_id))
672 def rot(turb, body, args):
673 """Implementation of the /rot command
675 The args string should be as follows:
677 [count|*] String to be rotated
679 That is, the first word of the string is an optional number (or
680 the character '*'). If this is a number it indicates an amount to
681 rotate each character in the string. If the count is '*' or is not
682 present, then the string will be rotated through all possible 25
685 The result of the rotation is returned (with Slack formatting) in
686 the body of the response so that Slack will provide it as a reply
687 to the user who submitted the slash command."""
689 channel_name = body['channel_name'][0]
690 response_url = body['response_url'][0]
691 channel_id = body['channel_id'][0]
693 result = turbot.rot.rot(args)
695 if (channel_name == "directmessage"):
696 requests.post(response_url,
697 json = {"text": result},
698 headers = {"Content-type": "application/json"})
700 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
704 commands["/rot"] = rot
706 def get_table_item(turb, table_name, key, value):
707 """Get an item from the database 'table_name' with 'key' as 'value'
709 Returns a tuple of (item, table) if found and (None, None) otherwise."""
711 table = turb.db.Table(table_name)
713 response = table.get_item(Key={key: value})
715 if 'Item' in response:
716 return (response['Item'], table)
720 def db_entry_for_channel(turb, channel_id):
721 """Given a channel ID return the database item for this channel
723 If this channel is a registered hunt or puzzle channel, return the
724 corresponding row from the database for this channel. Otherwise,
727 Note: If you need to specifically ensure that the channel is a
728 puzzle or a hunt, please call puzzle_for_channel or
729 hunt_for_channel respectively.
732 response = turb.table.query(
733 IndexName = "channel_id_index",
734 KeyConditionExpression=Key("channel_id").eq(channel_id)
737 if response['Count'] == 0:
740 return response['Items'][0]
743 def puzzle_for_channel(turb, channel_id):
745 """Given a channel ID return the puzzle from the database for this channel
747 If the given channel_id is a puzzle's channel, this function
748 returns a dict filled with the attributes from the puzzle's entry
751 Otherwise, this function returns None.
754 entry = db_entry_for_channel(turb, channel_id)
756 if entry and entry['SK'].startswith('puzzle-'):
761 def hunt_for_channel(turb, channel_id):
763 """Given a channel ID return the hunt from the database for this channel
765 This works whether the original channel is a primary hunt channel,
766 or if it is one of the channels of a puzzle belonging to the hunt.
768 Returns None if channel does not belong to a hunt, otherwise a
769 dictionary with all fields from the hunt's row in the table,
770 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
773 entry = db_entry_for_channel(turb, channel_id)
775 # We're done if this channel doesn't exist in the database at all
779 # Also done if this channel is a hunt channel
780 if entry['SK'].startswith('hunt-'):
783 # Otherwise, (the channel is in the database, but is not a hunt),
784 # we expect this to be a puzzle channel instead
785 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
787 # python3.9 has a built-in removeprefix but AWS only has python3.8
788 def remove_prefix(text, prefix):
789 if text.startswith(prefix):
790 return text[len(prefix):]
793 def hunt_rounds(turb, hunt_id):
794 """Returns array of strings giving rounds that exist in the given hunt"""
796 response = turb.table.query(
797 KeyConditionExpression=(
798 Key('hunt_id').eq(hunt_id) &
799 Key('SK').begins_with('round-')
803 if response['Count'] == 0:
806 return [remove_prefix(option['SK'], 'round-')
807 for option in response['Items']]
809 def puzzle(turb, body, args):
810 """Implementation of the /puzzle command
812 The args string can be a sub-command:
814 /puzzle new: Bring up a dialog to create a new puzzle
816 /puzzle edit: Edit the puzzle for the current channel
818 Or with no argument at all:
820 /puzzle: Print details of the current puzzle (if in a puzzle channel)
824 return new_puzzle(turb, body)
827 return edit_puzzle_command(turb, body)
830 return bot_reply("Unknown syntax for `/puzzle` command. " +
831 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
832 "and `/puzzle new` to display, edit, or create " +
835 # For no arguments we print the current puzzle as a reply
836 channel_id = body['channel_id'][0]
837 response_url = body['response_url'][0]
839 puzzle = puzzle_for_channel(turb, channel_id)
842 hunt = hunt_for_channel(turb, channel_id)
845 "This is not a puzzle channel, but is a hunt channel. "
846 + "If you want to create a new puzzle for this hunt, use "
850 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
851 + "channel, so the `/puzzle` command cannot work here.")
853 blocks = puzzle_blocks(puzzle, include_rounds=True)
855 # For a meta puzzle, also display the titles and solutions for all
856 # puzzles in the same round.
857 if puzzle.get('type', 'plain') == 'meta':
858 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
860 # Drop this puzzle itself from the report
861 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
863 for round in puzzle.get('rounds', [None]):
864 answers = round_quoted_puzzles_titles_answers(round, puzzles)
866 section_block(text_block(
867 "*Feeder solutions from round {}*".format(
868 round if round else "<none>"
870 section_block(text_block(answers))
873 requests.post(response_url,
874 json = {'blocks': blocks},
875 headers = {'Content-type': 'application/json'}
880 commands["/puzzle"] = puzzle
882 def new(turb, body, args):
883 """Implementation of the `/new` command
885 This can be used to create a new hunt ("/new hunt") or a new
886 puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
887 default behavior (as it is much more common).
889 This operations are identical to the existing "/hunt new" and
890 "/puzzle new". I don't know that that redundancy is actually
891 helpful in the interface. But at least having both allows us to
892 experiment and decide which is more natural and should be kept
897 return new_hunt_command(turb, body)
899 return new_puzzle(turb, body)
901 commands["/new"] = new
903 def new_puzzle(turb, body):
904 """Implementation of the "/puzzle new" command
906 This brings up a dialog box for creating a new puzzle.
909 channel_id = body['channel_id'][0]
910 trigger_id = body['trigger_id'][0]
912 hunt = hunt_for_channel(turb, channel_id)
915 return bot_reply("Sorry, this channel doesn't appear to "
916 + "be a hunt or puzzle channel")
918 round_options = hunt_rounds(turb, hunt['hunt_id'])
920 if len(round_options):
921 round_options_block = [
922 multi_select_block("Round(s)", "rounds",
923 "Existing round(s) this puzzle belongs to",
927 round_options_block = []
931 "private_metadata": json.dumps({
932 "hunt_id": hunt['hunt_id'],
934 "title": {"type": "plain_text", "text": "New Puzzle"},
935 "submit": { "type": "plain_text", "text": "Create" },
937 section_block(text_block("*For {}*".format(hunt['name']))),
938 input_block("Puzzle name", "name", "Name of the puzzle"),
939 input_block("Puzzle URL", "url", "External URL of puzzle",
941 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
942 * round_options_block,
943 input_block("New round(s)", "new_rounds",
944 "New round(s) this puzzle belongs to " +
950 result = turb.slack_client.views_open(trigger_id=trigger_id,
954 submission_handlers[result['view']['id']] = new_puzzle_submission
958 def new_puzzle_submission(turb, payload, metadata):
959 """Handler for the user submitting the new puzzle modal
961 This is the modal view presented to the user by the new_puzzle
965 # First, read all the various data from the request
966 meta = json.loads(metadata)
967 hunt_id = meta['hunt_id']
969 state = payload['view']['state']['values']
970 name = state['name']['name']['value']
971 url = state['url']['url']['value']
972 if state['meta']['meta']['selected_options']:
975 puzzle_type = 'plain'
976 if 'rounds' in state:
977 rounds = [option['value'] for option in
978 state['rounds']['rounds']['selected_options']]
981 new_rounds = state['new_rounds']['new_rounds']['value']
983 # Before doing anything, reject this puzzle if a puzzle already
984 # exists with the same URL.
986 existing = find_puzzle_for_url(turb, hunt_id, url)
988 return submission_error(
990 "Error: A puzzle with this URL already exists.")
992 # Create a Slack-channel-safe puzzle_id
993 puzzle_id = puzzle_id_from_name(name)
995 # Create a channel for the puzzle
996 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
999 response = turb.slack_client.conversations_create(
1000 name=hunt_dash_channel)
1001 except SlackApiError as e:
1002 return submission_error(
1004 "Error creating Slack channel {}: {}"
1005 .format(hunt_dash_channel, e.response['error']))
1007 channel_id = response['channel']['id']
1009 # Add any new rounds to the database
1011 for round in new_rounds.split(','):
1012 # Drop any leading/trailing spaces from the round name
1013 round = round.strip()
1014 # Ignore any empty string
1017 rounds.append(round)
1018 turb.table.put_item(
1021 'SK': 'round-' + round
1025 # Construct a puzzle dict
1028 "puzzle_id": puzzle_id,
1029 "channel_id": channel_id,
1031 "status": 'unsolved',
1038 puzzle['rounds'] = rounds
1040 # Finally, compute the appropriate sort key
1041 puzzle["SK"] = puzzle_sort_key(puzzle)
1043 # Insert the newly-created puzzle into the database
1044 turb.table.put_item(Item=puzzle)
1048 def state(turb, body, args):
1049 """Implementation of the /state command
1051 The args string should be a brief sentence describing where things
1052 stand or what's needed."""
1054 channel_id = body['channel_id'][0]
1056 old_puzzle = puzzle_for_channel(turb, channel_id)
1060 "Sorry, the /state command only works in a puzzle channel")
1062 # Make a deep copy of the puzzle object
1063 puzzle = puzzle_copy(old_puzzle)
1065 # Update the puzzle in the database
1066 puzzle['state'] = args
1067 turb.table.put_item(Item=puzzle)
1069 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1073 commands["/state"] = state
1075 def tag(turb, body, args):
1076 """Implementation of the `/tag` command.
1078 Arg is either a tag to add (optionally prefixed with '+'), or if
1079 prefixed with '-' is a tag to remove.
1083 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1084 + "or `/tag -TAG_TO_REMOVE`.")
1086 channel_id = body['channel_id'][0]
1088 old_puzzle = puzzle_for_channel(turb, channel_id)
1092 "Sorry, the /tag command only works in a puzzle channel")
1103 # Force tag to all uppercase
1106 # Reject a tag that is not alphabetic or underscore A-Z_
1107 if not re.match(r'^[A-Z0-9_]*$', tag):
1108 return bot_reply("Sorry, tags can only contain letters, numbers, "
1109 + "and the underscore character.")
1111 if action == 'remove':
1112 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1113 return bot_reply("Nothing to do. This puzzle is not tagged "
1114 + "with the tag: {}".format(tag))
1116 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1117 return bot_reply("Nothing to do. This puzzle is already tagged "
1118 + "with the tag: {}".format(tag))
1120 # OK. Error checking is done. Let's get to work
1122 # Make a deep copy of the puzzle object
1123 puzzle = puzzle_copy(old_puzzle)
1125 if action == 'remove':
1126 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1128 if 'tags' not in puzzle:
1129 puzzle['tags'] = [tag]
1131 puzzle['tags'].append(tag)
1133 turb.table.put_item(Item=puzzle)
1135 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1139 commands["/tag"] = tag
1141 def solved(turb, body, args):
1142 """Implementation of the /solved command
1144 The args string should be a confirmed solution."""
1146 channel_id = body['channel_id'][0]
1147 user_id = body['user_id'][0]
1149 old_puzzle = puzzle_for_channel(turb, channel_id)
1152 return bot_reply("Sorry, this is not a puzzle channel.")
1156 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1158 # Make a deep copy of the puzzle object
1159 puzzle = puzzle_copy(old_puzzle)
1161 # Set the status and solution fields in the database
1162 puzzle['status'] = 'solved'
1163 puzzle['solution'].append(args)
1164 if 'state' in puzzle:
1166 turb.table.put_item(Item=puzzle)
1168 # Report the solution to the puzzle's channel
1170 turb.slack_client, channel_id,
1171 "Puzzle mark solved by <@{}>: `{}`".format(user_id, args))
1173 # Also report the solution to the hunt channel
1174 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1176 turb.slack_client, hunt['channel_id'],
1177 "Puzzle <{}|{}> has been solved!".format(
1178 puzzle['channel_url'],
1182 # And update the puzzle's description
1183 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1187 commands["/solved"] = solved
1189 def hunt(turb, body, args):
1190 """Implementation of the /hunt command
1192 The (optional) args string can be used to filter which puzzles to
1193 display. The first word can be one of 'all', 'unsolved', or
1194 'solved' and can be used to display only puzzles with the given
1195 status. If this first word is missing, this command will display
1196 only unsolved puzzles by default.
1198 Any remaining text in the args string will be interpreted as
1199 search terms. These will be split into separate terms on space
1200 characters, (though quotation marks can be used to include a space
1201 character in a term). All terms must match on a puzzle in order
1202 for that puzzle to be included. But a puzzle will be considered to
1203 match if any of the puzzle title, round title, puzzle URL, puzzle
1204 state, puzzle type, tags, or puzzle solution match. Matching will
1205 be performed without regard to case sensitivity and the search
1206 terms can include regular expression syntax.
1210 channel_id = body['channel_id'][0]
1211 response_url = body['response_url'][0]
1213 # First, farm off "/hunt new" and "/hunt edit" a separate commands
1215 return new_hunt_command(turb, body)
1218 return edit_hunt_command(turb, body)
1222 # The first word can be a puzzle status and all remaining word
1223 # (if any) are search terms. _But_, if the first word is not a
1224 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1225 # words are search terms and we default status to 'unsolved'.
1226 split_args = args.split(' ', 1)
1227 status = split_args[0]
1228 if (len(split_args) > 1):
1229 terms = split_args[1]
1230 if status not in ('unsolved', 'solved', 'all'):
1236 # Separate search terms on spaces (but allow for quotation marks
1237 # to capture spaces in a search term)
1239 terms = shlex.split(terms)
1241 hunt = hunt_for_channel(turb, channel_id)
1244 return bot_reply("Sorry, this channel doesn't appear to "
1245 + "be a hunt or puzzle channel")
1247 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1249 requests.post(response_url,
1250 json = { 'blocks': blocks },
1251 headers = {'Content-type': 'application/json'}
1256 commands["/hunt"] = hunt
1258 def round(turb, body, args):
1259 """Implementation of the /round command
1261 Displays puzzles in the same round(s) as the puzzle for the
1264 The (optional) args string can be used to filter which puzzles to
1265 display. The first word can be one of 'all', 'unsolved', or
1266 'solved' and can be used to display only puzzles with the given
1267 status. If this first word is missing, this command will display
1268 all puzzles in the round by default.
1270 Any remaining text in the args string will be interpreted as
1271 search terms. These will be split into separate terms on space
1272 characters, (though quotation marks can be used to include a space
1273 character in a term). All terms must match on a puzzle in order
1274 for that puzzle to be included. But a puzzle will be considered to
1275 match if any of the puzzle title, round title, puzzle URL, puzzle
1276 state, or puzzle solution match. Matching will be performed
1277 without regard to case sensitivity and the search terms can
1278 include regular expression syntax.
1281 channel_id = body['channel_id'][0]
1282 response_url = body['response_url'][0]
1284 puzzle = puzzle_for_channel(turb, channel_id)
1285 hunt = hunt_for_channel(turb, channel_id)
1290 "This is not a puzzle channel, but is a hunt channel. "
1291 + "Use /hunt if you want to see all rounds for this hunt.")
1294 "Sorry, this channel doesn't appear to be a puzzle channel "
1295 + "so the `/round` command cannot work here.")
1299 # The first word can be a puzzle status and all remaining word
1300 # (if any) are search terms. _But_, if the first word is not a
1301 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1302 # words are search terms and we default status to 'unsolved'.
1303 split_args = args.split(' ', 1)
1304 status = split_args[0]
1305 if (len(split_args) > 1):
1306 terms = split_args[1]
1307 if status not in ('unsolved', 'solved', 'all'):
1313 # Separate search terms on spaces (but allow for quotation marks
1314 # to capture spaces in a search term)
1316 terms = shlex.split(terms)
1318 blocks = hunt_blocks(turb, hunt,
1319 puzzle_status=status, search_terms=terms,
1320 limit_to_rounds=puzzle.get('rounds', [])
1323 requests.post(response_url,
1324 json = { 'blocks': blocks },
1325 headers = {'Content-type': 'application/json'}
1330 commands["/round"] = round
1332 def help_command(turb, body, args):
1333 """Implementation of the /help command
1335 Displays help on how to use Turbot.
1338 channel_id = body['channel_id'][0]
1339 response_url = body['response_url'][0]
1340 user_id = body['user_id'][0]
1342 # Process "/help me" first. It calls out to have_you_tried rather
1343 # than going through our help system.
1345 # Also, it reports in the current channel, (where all other help
1346 # output is reported privately to the invoking user).
1348 to_try = "In response to <@{}> asking `/help me`:\n\n{}\n".format(
1349 user_id, have_you_tried())
1351 # We'll try first to reply directly to the channel (for the benefit
1352 # of anyone else in the same channel that might be stuck too.
1354 # But if this doesn't work, (direct message or private channel),
1355 # then we can instead reply with an ephemeral message by using
1358 turb.slack_client.chat_postMessage(
1359 channel=channel_id, text=to_try)
1360 except SlackApiError:
1361 requests.post(response_url,
1362 json = {"text": to_try},
1363 headers = {"Content-type": "application/json"})
1366 help_string = turbot_help(args)
1368 requests.post(response_url,
1369 json = {"text": help_string},
1370 headers = {"Content-type": "application/json"})
1374 commands["/help"] = help_command