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_list = puzzle.get("solution", [])
192 solution_str = ", ".join(solution_list)
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("Tag(s)", "tags",
220 "Tags for this puzzle (comma separated)",
221 initial_value=", ".join(puzzle.get("tags", [])),
223 input_block("State", "state",
224 "State of this puzzle (partial progress, next steps)",
225 initial_value=puzzle.get("state", None),
228 "Puzzle status", "Solved", "solved",
229 checked=(puzzle.get('status', 'unsolved') == 'solved')),
230 input_block("Solution", "solution",
231 "Solution(s) (comma-separated if multiple)",
232 initial_value=solution_str,
237 result = turb.slack_client.views_open(trigger_id=trigger_id,
241 submission_handlers[result['view']['id']] = edit_puzzle_submission
245 def edit_puzzle_submission(turb, payload, metadata):
246 """Handler for the user submitting the edit puzzle modal
248 This is the modal view presented to the user by the edit_puzzle
254 # First, read all the various data from the request
255 meta = json.loads(metadata)
256 puzzle['hunt_id'] = meta['hunt_id']
257 puzzle['SK'] = meta['SK']
258 puzzle['puzzle_id'] = meta['puzzle_id']
259 puzzle['channel_id'] = meta['channel_id']
260 puzzle['channel_url'] = meta['channel_url']
261 puzzle['sheet_url'] = meta['sheet_url']
263 state = payload['view']['state']['values']
264 user_id = payload['user']['id']
266 puzzle['name'] = state['name']['name']['value']
267 url = state['url']['url']['value']
270 if state['meta']['meta']['selected_options']:
271 puzzle['type'] = 'meta'
273 puzzle['type'] = 'plain'
274 if 'rounds' in state:
275 rounds = [option['value'] for option in
276 state['rounds']['rounds']['selected_options']]
278 puzzle['rounds'] = rounds
279 new_rounds = state['new_rounds']['new_rounds']['value']
280 tags = state['tags']['tags']['value']
281 puzzle_state = state['state']['state']['value']
283 puzzle['state'] = puzzle_state
284 if state['solved']['solved']['selected_options']:
285 puzzle['status'] = 'solved'
287 puzzle['status'] = 'unsolved'
288 puzzle['solution'] = []
289 solution = state['solution']['solution']['value']
291 # Construct a list from a set to avoid any duplicates
292 puzzle['solution'] = list({
293 sol.strip() for sol in solution.split(',')
296 # Verify that there's a solution if the puzzle is mark solved
297 if puzzle['status'] == 'solved' and not puzzle['solution']:
298 return submission_error("solution",
299 "A solved puzzle requires a solution.")
301 if puzzle['status'] == 'unsolved' and puzzle['solution']:
302 return submission_error("solution",
303 "An unsolved puzzle should have no solution.")
305 # Add any new rounds to the database
307 if 'rounds' not in puzzle:
308 puzzle['rounds'] = []
309 for round in new_rounds.split(','):
310 # Drop any leading/trailing spaces from the round name
311 round = round.strip()
312 # Ignore any empty string
315 puzzle['rounds'].append(round)
318 'hunt_id': puzzle['hunt_id'],
319 'SK': 'round-' + round
326 for tag in tags.split(','):
327 # Drop any leading/trailing spaces from the tag
328 tag = tag.strip().upper()
329 # Ignore any empty string
332 # Reject a tag that is not alphabetic or underscore A-Z_
333 if not re.match(r'^[A-Z0-9_]*$', tag):
334 return submission_error(
336 "Error: Tags can only contain letters, numbers, "
337 + "and the underscore character."
339 puzzle['tags'].append(tag)
341 # Get old puzzle from the database (to determine what's changed)
342 old_puzzle = find_puzzle_for_sort_key(turb,
346 # If we are changing puzzle type (meta -> plain or plain -> meta)
347 # then the sort key has to change, so compute the new one and delete
348 # the old item from the database.
350 # XXX: We should really be using a transaction here to combine the
351 # delete_item and the put_item into a single transaction, but
352 # the boto interface is annoying in that transactions are only on
353 # the "Client" object which has a totally different interface than
354 # the "Table" object I've been using so I haven't figured out how
357 if puzzle['type'] != old_puzzle.get('type', 'plain'):
358 puzzle['SK'] = puzzle_sort_key(puzzle)
359 turb.table.delete_item(Key={
360 'hunt_id': old_puzzle['hunt_id'],
361 'SK': old_puzzle['SK']
364 # Update the puzzle in the database
365 turb.table.put_item(Item=puzzle)
367 # Inform the puzzle channel about the edit
368 edit_message = "Puzzle edited by <@{}>".format(user_id)
369 blocks = ([section_block(text_block(edit_message+":\n"))] +
370 puzzle_blocks(puzzle, include_rounds=True))
372 turb.slack_client, puzzle['channel_id'],
373 edit_message, blocks=blocks)
375 # Also inform the hunt if the puzzle's solved status changed
376 if puzzle['status'] != old_puzzle['status']:
377 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
378 if puzzle['status'] == 'solved':
379 message = "Puzzle <{}|{}> has been solved!".format(
380 puzzle['channel_url'],
383 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
384 puzzle['channel_url'],
386 slack_send_message(turb.slack_client, hunt['channel_id'], message)
388 # We need to set the channel topic if any of puzzle name, url,
389 # state, status, or solution, has changed. Let's just do that
390 # unconditionally here.
391 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
395 def edit_hunt_command(turb, body):
396 """Implementation of the `/hunt edit` command
398 As dispatched from the hunt() function.
401 channel_id = body['channel_id'][0]
402 trigger_id = body['trigger_id'][0]
404 hunt = hunt_for_channel(turb, channel_id)
407 return bot_reply("Sorry, this does not appear to be a hunt channel.")
409 return edit_hunt(turb, hunt, trigger_id)
411 def edit_hunt_button(turb, payload):
412 """Handler for the action of user pressing an edit_hunt button"""
414 hunt_id = payload['actions'][0]['action_id']
415 trigger_id = payload['trigger_id']
417 hunt = find_hunt_for_hunt_id(turb, hunt_id)
420 return bot_reply("Error: Hunt not found.")
422 return edit_hunt(turb, hunt, trigger_id)
424 actions['button']['edit_hunt'] = edit_hunt_button
426 def edit_hunt(turb, hunt, trigger_id):
427 """Common code for implementing an edit hunt dialog
429 This implementation is common whether the edit operation was invoked
430 by a button (edit_hunt_button) or a command (edit_hunt_command).
435 "private_metadata": json.dumps({
436 "hunt_id": hunt["hunt_id"],
438 "is_hunt": hunt["is_hunt"],
439 "channel_id": hunt["channel_id"],
440 "sheet_url": hunt["sheet_url"],
441 "folder_id": hunt["folder_id"],
443 "title": { "type": "plain_text", "text": "Edit Hunt" },
444 "submit": { "type": "plain_text", "text": "Save" },
446 input_block("Hunt name", "name", "Name of the hunt",
447 initial_value=hunt["name"]),
448 input_block("Hunt URL", "url", "External URL of hunt",
449 initial_value=hunt.get("url", None),
451 checkbox_block("Is this hunt active?", "Active", "active",
452 checked=(hunt.get('active', False)))
456 result = turb.slack_client.views_open(trigger_id=trigger_id,
460 submission_handlers[result['view']['id']] = edit_hunt_submission
464 def edit_hunt_submission(turb, payload, metadata):
465 """Handler for the user submitting the edit hunt modal
467 This is the modal view presented by the edit_hunt function above.
472 # First, read all the various data from the request
473 meta = json.loads(metadata)
474 hunt['hunt_id'] = meta['hunt_id']
475 hunt['SK'] = meta['SK']
476 hunt['is_hunt'] = meta['is_hunt']
477 hunt['channel_id'] = meta['channel_id']
478 hunt['sheet_url'] = meta['sheet_url']
479 hunt['folder_id'] = meta['folder_id']
481 state = payload['view']['state']['values']
482 user_id = payload['user']['id']
484 hunt['name'] = state['name']['name']['value']
485 url = state['url']['url']['value']
489 if state['active']['active']['selected_options']:
490 hunt['active'] = True
492 hunt['active'] = False
494 # Update the hunt in the database
495 turb.table.put_item(Item=hunt)
497 # Inform the hunt channel about the edit
498 edit_message = "Hunt edited by <@{}>".format(user_id)
500 section_block(text_block(edit_message)),
501 section_block(text_block("Hunt name: {}".format(hunt['name']))),
504 url = hunt.get('url', None)
507 section_block(text_block("Hunt URL: {}".format(hunt['url'])))
511 turb.slack_client, hunt['channel_id'],
512 edit_message, blocks=blocks)
516 def new_hunt_command(turb, body):
517 """Implementation of the '/hunt new' command
519 As dispatched from the hunt() function.
522 trigger_id = body['trigger_id'][0]
524 return new_hunt(turb, trigger_id)
526 def new_hunt_button(turb, payload):
527 """Handler for the action of user pressing the new_hunt button"""
529 trigger_id = payload['trigger_id']
531 return new_hunt(turb, trigger_id)
533 def new_hunt(turb, trigger_id):
534 """Common code for implementing a new hunt dialog
536 This implementation is common whether the operations was invoked
537 by a button (new_hunt_button) or a command (new_hunt_command).
542 "private_metadata": json.dumps({}),
543 "title": { "type": "plain_text", "text": "New Hunt" },
544 "submit": { "type": "plain_text", "text": "Create" },
546 input_block("Hunt name", "name", "Name of the hunt"),
547 input_block("Hunt ID", "hunt_id",
548 "Used as puzzle channel prefix "
549 + "(no spaces nor punctuation)"),
550 input_block("Hunt URL", "url", "External URL of hunt",
555 result = turb.slack_client.views_open(trigger_id=trigger_id,
558 submission_handlers[result['view']['id']] = new_hunt_submission
562 actions['button']['new_hunt'] = new_hunt_button
564 def new_hunt_submission(turb, payload, metadata):
565 """Handler for the user submitting the new hunt modal
567 This is the modal view presented to the user by the new_hunt
570 state = payload['view']['state']['values']
571 user_id = payload['user']['id']
572 name = state['name']['name']['value']
573 hunt_id = state['hunt_id']['hunt_id']['value']
574 url = state['url']['url']['value']
576 # Validate that the hunt_id contains no invalid characters
577 if not re.match(valid_id_re, hunt_id):
578 return submission_error("hunt_id",
579 "Hunt ID can only contain lowercase letters, "
580 + "numbers, and underscores")
582 # Check to see if the turbot table exists
584 exists = turb.table.table_status in ("CREATING", "UPDATING",
589 # Create the turbot table if necessary.
591 turb.table = turb.db.create_table(
594 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
595 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
597 AttributeDefinitions=[
598 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
599 {'AttributeName': 'SK', 'AttributeType': 'S'},
600 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
601 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
602 {'AttributeName': 'url', 'AttributeType': 'S'},
603 {'AttributeName': 'puzzle_id', 'AttributeType': 'S'}
605 ProvisionedThroughput={
606 'ReadCapacityUnits': 5,
607 'WriteCapacityUnits': 5
609 GlobalSecondaryIndexes=[
611 'IndexName': 'channel_id_index',
613 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
616 'ProjectionType': 'ALL'
618 'ProvisionedThroughput': {
619 'ReadCapacityUnits': 5,
620 'WriteCapacityUnits': 5
624 'IndexName': 'is_hunt_index',
626 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
629 'ProjectionType': 'ALL'
631 'ProvisionedThroughput': {
632 'ReadCapacityUnits': 5,
633 'WriteCapacityUnits': 5
637 LocalSecondaryIndexes = [
639 'IndexName': 'url_index',
641 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
642 {'AttributeName': 'url', 'KeyType': 'RANGE'},
645 'ProjectionType': 'ALL'
649 'IndexName': 'puzzle_id_index',
651 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
652 {'AttributeName': 'puzzle_id', 'KeyType': 'RANGE'},
655 'ProjectionType': 'ALL'
660 return submission_error(
662 "Still bootstrapping turbot table. Try again in a minute, please.")
664 # Create a channel for the hunt
666 response = turb.slack_client.conversations_create(name=hunt_id)
667 except SlackApiError as e:
668 return submission_error("hunt_id",
669 "Error creating Slack channel: {}"
670 .format(e.response['error']))
672 channel_id = response['channel']['id']
674 # Insert the newly-created hunt into the database
675 # (leaving it as non-active for now until the channel-created handler
676 # finishes fixing it up with a sheet and a companion table)
679 "SK": "hunt-{}".format(hunt_id),
681 "channel_id": channel_id,
687 turb.table.put_item(Item=item)
689 # Invite the initiating user to the channel
690 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
694 def view_submission(turb, payload):
695 """Handler for Slack interactive view submission
697 Specifically, those that have a payload type of 'view_submission'"""
699 view_id = payload['view']['id']
700 metadata = payload['view']['private_metadata']
702 if view_id in submission_handlers:
703 return submission_handlers[view_id](turb, payload, metadata)
705 print("Error: Unknown view ID: {}".format(view_id))
710 def rot(turb, body, args):
711 """Implementation of the /rot command
713 The args string should be as follows:
715 [count|*] String to be rotated
717 That is, the first word of the string is an optional number (or
718 the character '*'). If this is a number it indicates an amount to
719 rotate each character in the string. If the count is '*' or is not
720 present, then the string will be rotated through all possible 25
723 The result of the rotation is returned (with Slack formatting) in
724 the body of the response so that Slack will provide it as a reply
725 to the user who submitted the slash command."""
727 channel_name = body['channel_name'][0]
728 response_url = body['response_url'][0]
729 channel_id = body['channel_id'][0]
731 result = turbot.rot.rot(args)
733 if (channel_name == "directmessage"):
734 requests.post(response_url,
735 json = {"text": result},
736 headers = {"Content-type": "application/json"})
738 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
742 commands["/rot"] = rot
744 def get_table_item(turb, table_name, key, value):
745 """Get an item from the database 'table_name' with 'key' as 'value'
747 Returns a tuple of (item, table) if found and (None, None) otherwise."""
749 table = turb.db.Table(table_name)
751 response = table.get_item(Key={key: value})
753 if 'Item' in response:
754 return (response['Item'], table)
758 def db_entry_for_channel(turb, channel_id):
759 """Given a channel ID return the database item for this channel
761 If this channel is a registered hunt or puzzle channel, return the
762 corresponding row from the database for this channel. Otherwise,
765 Note: If you need to specifically ensure that the channel is a
766 puzzle or a hunt, please call puzzle_for_channel or
767 hunt_for_channel respectively.
770 response = turb.table.query(
771 IndexName = "channel_id_index",
772 KeyConditionExpression=Key("channel_id").eq(channel_id)
775 if response['Count'] == 0:
778 return response['Items'][0]
781 def puzzle_for_channel(turb, channel_id):
783 """Given a channel ID return the puzzle from the database for this channel
785 If the given channel_id is a puzzle's channel, this function
786 returns a dict filled with the attributes from the puzzle's entry
789 Otherwise, this function returns None.
792 entry = db_entry_for_channel(turb, channel_id)
794 if entry and entry['SK'].startswith('puzzle-'):
799 def hunt_for_channel(turb, channel_id):
801 """Given a channel ID return the hunt from the database for this channel
803 This works whether the original channel is a primary hunt channel,
804 or if it is one of the channels of a puzzle belonging to the hunt.
806 Returns None if channel does not belong to a hunt, otherwise a
807 dictionary with all fields from the hunt's row in the table,
808 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
811 entry = db_entry_for_channel(turb, channel_id)
813 # We're done if this channel doesn't exist in the database at all
817 # Also done if this channel is a hunt channel
818 if entry['SK'].startswith('hunt-'):
821 # Otherwise, (the channel is in the database, but is not a hunt),
822 # we expect this to be a puzzle channel instead
823 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
825 # python3.9 has a built-in removeprefix but AWS only has python3.8
826 def remove_prefix(text, prefix):
827 if text.startswith(prefix):
828 return text[len(prefix):]
831 def hunt_rounds(turb, hunt_id):
832 """Returns array of strings giving rounds that exist in the given hunt"""
834 response = turb.table.query(
835 KeyConditionExpression=(
836 Key('hunt_id').eq(hunt_id) &
837 Key('SK').begins_with('round-')
841 if response['Count'] == 0:
844 return [remove_prefix(option['SK'], 'round-')
845 for option in response['Items']]
847 def puzzle(turb, body, args):
848 """Implementation of the /puzzle command
850 The args string can be a sub-command:
852 /puzzle new: Bring up a dialog to create a new puzzle
854 /puzzle edit: Edit the puzzle for the current channel
856 Or with no argument at all:
858 /puzzle: Print details of the current puzzle (if in a puzzle channel)
862 return new_puzzle(turb, body)
865 return edit_puzzle_command(turb, body)
868 return bot_reply("Unknown syntax for `/puzzle` command. " +
869 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
870 "and `/puzzle new` to display, edit, or create " +
873 # For no arguments we print the current puzzle as a reply
874 channel_id = body['channel_id'][0]
875 response_url = body['response_url'][0]
877 puzzle = puzzle_for_channel(turb, channel_id)
880 hunt = hunt_for_channel(turb, channel_id)
883 "This is not a puzzle channel, but is a hunt channel. "
884 + "If you want to create a new puzzle for this hunt, use "
888 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
889 + "channel, so the `/puzzle` command cannot work here.")
891 blocks = puzzle_blocks(puzzle, include_rounds=True)
893 # For a meta puzzle, also display the titles and solutions for all
894 # puzzles in the same round.
895 if puzzle.get('type', 'plain') == 'meta':
896 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
898 # Drop this puzzle itself from the report
899 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
901 for round in puzzle.get('rounds', [None]):
902 answers = round_quoted_puzzles_titles_answers(round, puzzles)
904 section_block(text_block(
905 "*Feeder solutions from round {}*".format(
906 round if round else "<none>"
908 section_block(text_block(answers))
911 requests.post(response_url,
912 json = {'blocks': blocks},
913 headers = {'Content-type': 'application/json'}
918 commands["/puzzle"] = puzzle
920 def new(turb, body, args):
921 """Implementation of the `/new` command
923 This can be used to create a new hunt ("/new hunt") or a new
924 puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
925 default behavior (as it is much more common).
927 This operations are identical to the existing "/hunt new" and
928 "/puzzle new". I don't know that that redundancy is actually
929 helpful in the interface. But at least having both allows us to
930 experiment and decide which is more natural and should be kept
935 return new_hunt_command(turb, body)
937 return new_puzzle(turb, body)
939 commands["/new"] = new
941 def new_puzzle(turb, body):
942 """Implementation of the "/puzzle new" command
944 This brings up a dialog box for creating a new puzzle.
947 channel_id = body['channel_id'][0]
948 trigger_id = body['trigger_id'][0]
950 hunt = hunt_for_channel(turb, channel_id)
953 return bot_reply("Sorry, this channel doesn't appear to "
954 + "be a hunt or puzzle channel")
956 # We used puzzle (if available) to select the initial round(s)
957 puzzle = puzzle_for_channel(turb, channel_id)
958 initial_rounds = None
960 initial_rounds=puzzle.get("rounds", None)
962 round_options = hunt_rounds(turb, hunt['hunt_id'])
964 if len(round_options):
965 round_options_block = [
966 multi_select_block("Round(s)", "rounds",
967 "Existing round(s) this puzzle belongs to",
969 initial_options=initial_rounds)
972 round_options_block = []
976 "private_metadata": json.dumps({
977 "hunt_id": hunt['hunt_id'],
979 "title": {"type": "plain_text", "text": "New Puzzle"},
980 "submit": { "type": "plain_text", "text": "Create" },
982 section_block(text_block("*For {}*".format(hunt['name']))),
983 input_block("Puzzle name", "name", "Name of the puzzle"),
984 input_block("Puzzle URL", "url", "External URL of puzzle",
986 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
987 * round_options_block,
988 input_block("New round(s)", "new_rounds",
989 "New round(s) this puzzle belongs to " +
992 input_block("Tag(s)", "tags",
993 "Tags for this puzzle (comma separated)",
998 result = turb.slack_client.views_open(trigger_id=trigger_id,
1002 submission_handlers[result['view']['id']] = new_puzzle_submission
1006 def new_puzzle_submission(turb, payload, metadata):
1007 """Handler for the user submitting the new puzzle modal
1009 This is the modal view presented to the user by the new_puzzle
1013 # First, read all the various data from the request
1014 meta = json.loads(metadata)
1015 hunt_id = meta['hunt_id']
1017 state = payload['view']['state']['values']
1019 # And start loading data into a puzzle dict
1021 puzzle['hunt_id'] = hunt_id
1022 puzzle['name'] = state['name']['name']['value']
1023 url = state['url']['url']['value']
1026 if state['meta']['meta']['selected_options']:
1027 puzzle['type'] = 'meta'
1029 puzzle['type'] = 'plain'
1030 if 'rounds' in state:
1031 rounds = [option['value'] for option in
1032 state['rounds']['rounds']['selected_options']]
1035 new_rounds = state['new_rounds']['new_rounds']['value']
1036 tags = state['tags']['tags']['value']
1038 # Create a Slack-channel-safe puzzle_id
1039 puzzle['puzzle_id'] = puzzle_id_from_name(puzzle['name'])
1041 # Before doing anything, reject this puzzle if a puzzle already
1042 # exists with the same puzzle_id or url
1043 existing = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle['puzzle_id'])
1045 return submission_error(
1047 "Error: This name collides with an existing puzzle.")
1050 existing = find_puzzle_for_url(turb, hunt_id, url)
1052 return submission_error(
1054 "Error: A puzzle with this URL already exists.")
1056 # Add any new rounds to the database
1058 for round in new_rounds.split(','):
1059 # Drop any leading/trailing spaces from the round name
1060 round = round.strip()
1061 # Ignore any empty string
1064 rounds.append(round)
1065 turb.table.put_item(
1068 'SK': 'round-' + round
1075 for tag in tags.split(','):
1076 # Drop any leading/trailing spaces from the tag
1077 tag = tag.strip().upper()
1078 # Ignore any empty string
1081 # Reject a tag that is not alphabetic or underscore A-Z_
1082 if not re.match(r'^[A-Z0-9_]*$', tag):
1083 return submission_error(
1085 "Error: Tags can only contain letters, numbers, "
1086 + "and the underscore character."
1088 puzzle['tags'].append(tag)
1091 puzzle['rounds'] = rounds
1093 puzzle['solution'] = []
1094 puzzle['status'] = 'unsolved'
1096 # Create a channel for the puzzle
1097 channel_name = puzzle_channel_name(puzzle)
1100 response = turb.slack_client.conversations_create(
1102 except SlackApiError as e:
1103 return submission_error(
1105 "Error creating Slack channel {}: {}"
1106 .format(channel_name, e.response['error']))
1108 puzzle['channel_id'] = response['channel']['id']
1110 # Finally, compute the appropriate sort key
1111 puzzle["SK"] = puzzle_sort_key(puzzle)
1113 # Insert the newly-created puzzle into the database
1114 turb.table.put_item(Item=puzzle)
1118 def state(turb, body, args):
1119 """Implementation of the /state command
1121 The args string should be a brief sentence describing where things
1122 stand or what's needed."""
1124 channel_id = body['channel_id'][0]
1126 old_puzzle = puzzle_for_channel(turb, channel_id)
1130 "Sorry, the /state command only works in a puzzle channel")
1132 # Make a deep copy of the puzzle object
1133 puzzle = puzzle_copy(old_puzzle)
1135 # Update the puzzle in the database
1136 puzzle['state'] = args
1137 turb.table.put_item(Item=puzzle)
1139 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1143 commands["/state"] = state
1145 def tag(turb, body, args):
1146 """Implementation of the `/tag` command.
1148 Arg is either a tag to add (optionally prefixed with '+'), or if
1149 prefixed with '-' is a tag to remove.
1153 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1154 + "or `/tag -TAG_TO_REMOVE`.")
1156 channel_id = body['channel_id'][0]
1158 old_puzzle = puzzle_for_channel(turb, channel_id)
1162 "Sorry, the /tag command only works in a puzzle channel")
1173 # Force tag to all uppercase
1176 # Reject a tag that is not alphabetic or underscore A-Z_
1177 if not re.match(r'^[A-Z0-9_]*$', tag):
1178 return bot_reply("Sorry, tags can only contain letters, numbers, "
1179 + "and the underscore character.")
1181 if action == 'remove':
1182 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1183 return bot_reply("Nothing to do. This puzzle is not tagged "
1184 + "with the tag: {}".format(tag))
1186 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1187 return bot_reply("Nothing to do. This puzzle is already tagged "
1188 + "with the tag: {}".format(tag))
1190 # OK. Error checking is done. Let's get to work
1192 # Make a deep copy of the puzzle object
1193 puzzle = puzzle_copy(old_puzzle)
1195 if action == 'remove':
1196 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1198 if 'tags' not in puzzle:
1199 puzzle['tags'] = [tag]
1201 puzzle['tags'].append(tag)
1203 turb.table.put_item(Item=puzzle)
1205 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1209 commands["/tag"] = tag
1211 def solved(turb, body, args):
1212 """Implementation of the /solved command
1214 The args string should be a confirmed solution."""
1216 channel_id = body['channel_id'][0]
1217 user_id = body['user_id'][0]
1219 old_puzzle = puzzle_for_channel(turb, channel_id)
1222 return bot_reply("Sorry, this is not a puzzle channel.")
1226 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1228 # Make a deep copy of the puzzle object
1229 puzzle = puzzle_copy(old_puzzle)
1231 # Set the status and solution fields in the database
1232 puzzle['status'] = 'solved'
1234 # Don't append a duplicate solution
1235 if args not in puzzle['solution']:
1236 puzzle['solution'].append(args)
1237 if 'state' in puzzle:
1239 turb.table.put_item(Item=puzzle)
1241 # Report the solution to the puzzle's channel
1243 turb.slack_client, channel_id,
1244 "Puzzle marked solved by <@{}>: `{}`".format(user_id, args))
1246 # Also report the solution to the hunt channel
1247 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1249 turb.slack_client, hunt['channel_id'],
1250 "Puzzle <{}|{}> has been solved!".format(
1251 puzzle['channel_url'],
1255 # And update the puzzle's description
1256 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1260 commands["/solved"] = solved
1262 def delete(turb, body, args):
1263 """Implementation of the /delete command
1265 The argument to this command is the ID of a hunt.
1267 The command will report an error if the specified hunt is active.
1269 If the hunt is inactive, this command will archive all channels
1274 return bot_reply("Error, no hunt provided. Usage: `/delete HUNT_ID`")
1277 hunt = find_hunt_for_hunt_id(turb, hunt_id)
1280 return bot_reply("Error, no hunt named \"{}\" exists.".format(hunt_id))
1284 "Error, refusing to delete active hunt \"{}\".".format(hunt_id)
1287 if hunt['hunt_id'] != hunt_id:
1289 "Error, expected hunt ID of \"{}\" but found \"{}\".".format(
1290 hunt_id, hunt['hunt_id']
1294 puzzles = hunt_puzzles_for_hunt_id(turb, hunt_id)
1296 for puzzle in puzzles:
1297 channel_id = puzzle['channel_id']
1298 turb.slack_client.conversations_archive(channel=channel_id)
1300 turb.slack_client.conversations_archive(channel=hunt['channel_id'])
1304 commands["/delete"] = delete
1306 def hunt(turb, body, args):
1307 """Implementation of the /hunt command
1309 The (optional) args string can be used to filter which puzzles to
1310 display. The first word can be one of 'all', 'unsolved', or
1311 'solved' and can be used to display only puzzles with the given
1312 status. If this first word is missing, this command will display
1313 only unsolved puzzles by default.
1315 Any remaining text in the args string will be interpreted as
1316 search terms. These will be split into separate terms on space
1317 characters, (though quotation marks can be used to include a space
1318 character in a term). All terms must match on a puzzle in order
1319 for that puzzle to be included. But a puzzle will be considered to
1320 match if any of the puzzle title, round title, puzzle URL, puzzle
1321 state, puzzle type, tags, or puzzle solution match. Matching will
1322 be performed without regard to case sensitivity and the search
1323 terms can include regular expression syntax.
1327 channel_id = body['channel_id'][0]
1328 response_url = body['response_url'][0]
1330 # First, farm off "/hunt new" and "/hunt edit" a separate commands
1332 return new_hunt_command(turb, body)
1335 return edit_hunt_command(turb, body)
1339 # The first word can be a puzzle status and all remaining word
1340 # (if any) are search terms. _But_, if the first word is not a
1341 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1342 # words are search terms and we default status to 'unsolved'.
1343 split_args = args.split(' ', 1)
1344 status = split_args[0]
1345 if (len(split_args) > 1):
1346 terms = split_args[1]
1347 if status not in ('unsolved', 'solved', 'all'):
1353 # Separate search terms on spaces (but allow for quotation marks
1354 # to capture spaces in a search term)
1356 terms = shlex.split(terms)
1358 hunt = hunt_for_channel(turb, channel_id)
1361 return bot_reply("Sorry, this channel doesn't appear to "
1362 + "be a hunt or puzzle channel")
1364 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1366 for block in blocks:
1367 if len(block) > 100:
1369 requests.post(response_url,
1370 json = { 'blocks': block },
1371 headers = {'Content-type': 'application/json'}
1376 commands["/hunt"] = hunt
1378 def round(turb, body, args):
1379 """Implementation of the /round command
1381 Displays puzzles in the same round(s) as the puzzle for the
1384 The (optional) args string can be used to filter which puzzles to
1385 display. The first word can be one of 'all', 'unsolved', or
1386 'solved' and can be used to display only puzzles with the given
1387 status. If this first word is missing, this command will display
1388 all puzzles in the round by default.
1390 Any remaining text in the args string will be interpreted as
1391 search terms. These will be split into separate terms on space
1392 characters, (though quotation marks can be used to include a space
1393 character in a term). All terms must match on a puzzle in order
1394 for that puzzle to be included. But a puzzle will be considered to
1395 match if any of the puzzle title, round title, puzzle URL, puzzle
1396 state, or puzzle solution match. Matching will be performed
1397 without regard to case sensitivity and the search terms can
1398 include regular expression syntax.
1401 channel_id = body['channel_id'][0]
1402 response_url = body['response_url'][0]
1404 puzzle = puzzle_for_channel(turb, channel_id)
1405 hunt = hunt_for_channel(turb, channel_id)
1410 "This is not a puzzle channel, but is a hunt channel. "
1411 + "Use /hunt if you want to see all rounds for this hunt.")
1414 "Sorry, this channel doesn't appear to be a puzzle channel "
1415 + "so the `/round` command cannot work here.")
1419 # The first word can be a puzzle status and all remaining word
1420 # (if any) are search terms. _But_, if the first word is not a
1421 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1422 # words are search terms and we default status to 'unsolved'.
1423 split_args = args.split(' ', 1)
1424 status = split_args[0]
1425 if (len(split_args) > 1):
1426 terms = split_args[1]
1427 if status not in ('unsolved', 'solved', 'all'):
1433 # Separate search terms on spaces (but allow for quotation marks
1434 # to capture spaces in a search term)
1436 terms = shlex.split(terms)
1438 blocks = hunt_blocks(turb, hunt,
1439 puzzle_status=status, search_terms=terms,
1440 limit_to_rounds=puzzle.get('rounds', [])
1443 for block in blocks:
1444 if len(block) > 100:
1446 requests.post(response_url,
1447 json = { 'blocks': block },
1448 headers = {'Content-type': 'application/json'}
1453 commands["/round"] = round
1455 def help_command(turb, body, args):
1456 """Implementation of the /help command
1458 Displays help on how to use Turbot.
1461 channel_id = body['channel_id'][0]
1462 response_url = body['response_url'][0]
1463 user_id = body['user_id'][0]
1465 # Process "/help me" first. It calls out to have_you_tried rather
1466 # than going through our help system.
1468 # Also, it reports in the current channel, (where all other help
1469 # output is reported privately to the invoking user).
1471 to_try = "In response to <@{}> asking `/help me`:\n\n{}\n".format(
1472 user_id, have_you_tried())
1474 # We'll try first to reply directly to the channel (for the benefit
1475 # of anyone else in the same channel that might be stuck too.
1477 # But if this doesn't work, (direct message or private channel),
1478 # then we can instead reply with an ephemeral message by using
1481 turb.slack_client.chat_postMessage(
1482 channel=channel_id, text=to_try)
1483 except SlackApiError:
1484 requests.post(response_url,
1485 json = {"text": to_try},
1486 headers = {"Content-type": "application/json"})
1489 help_string = turbot_help(args)
1491 requests.post(response_url,
1492 json = {"text": help_string},
1493 headers = {"Content-type": "application/json"})
1497 commands["/help"] = help_command