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,
11 from turbot.puzzle import (
13 find_puzzle_for_sort_key,
14 find_puzzle_for_puzzle_id,
15 puzzle_update_channel_and_sheet,
22 from turbot.round import round_quoted_puzzles_titles_answers
23 from turbot.help import turbot_help
24 from turbot.have_you_tried import have_you_tried
31 from botocore.exceptions import ClientError
32 from boto3.dynamodb.conditions import Key
33 from turbot.slack import slack_send_message
37 actions['button'] = {}
39 submission_handlers = {}
41 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
43 # Note: This restriction not only allows for hunt and puzzle ID values to
44 # be used as Slack channel names, but it also allows for '-' as a valid
45 # separator between a hunt and a puzzle ID (for example in the puzzle
46 # edit dialog where a single attribute must capture both values).
47 valid_id_re = r'^[_a-z0-9]+$'
49 lambda_ok = {'statusCode': 200}
51 def bot_reply(message):
52 """Construct a return value suitable for a bot reply
54 This is suitable as a way to give an error back to the user who
55 initiated a slash command, for example."""
62 def submission_error(field, error):
63 """Construct an error suitable for returning for an invalid submission.
65 Returning this value will prevent a submission and alert the user that
66 the given field is invalid because of the given error."""
68 print("Rejecting invalid modal submission: {}".format(error))
73 "Content-Type": "application/json"
76 "response_action": "errors",
83 def multi_static_select(turb, payload):
84 """Handler for the action of user entering a multi-select value"""
88 actions['multi_static_select'] = {"*": multi_static_select}
90 def edit(turb, body, args):
92 """Implementation of the `/edit` command
94 This can be used as `/edit` (with no arguments) in either a hunt
95 or a puzzle channel to edit that hunt or puzzle. It can also be
96 called explicitly as `/edit hunt` to edit a hunt even from a
99 In any case, the operation is identical to `/hunt edit` or
103 # If we have an explicit argument, do what it says to do
105 return edit_hunt_command(turb, body)
108 return edit_puzzle_command(turb, body)
110 # Any other argument string is an error
112 return bot_reply("Error: Unexpected argument: {}\n".format(args) +
113 "Usage: `/edit puzzle`, `/edit hunt`, or " +
114 "`/edit` (to choose based on channel)"
117 # No explicit argument, so select what to edit based on the current channel
118 channel_id = body['channel_id'][0]
119 trigger_id = body['trigger_id'][0]
121 puzzle = puzzle_for_channel(turb, channel_id)
123 return edit_puzzle(turb, puzzle, trigger_id)
125 hunt = hunt_for_channel(turb, channel_id)
127 return edit_hunt(turb, hunt, trigger_id)
129 return bot_reply("Sorry, `/edit` only works in a hunt or puzzle channel.")
131 commands["/edit"] = edit
134 def edit_puzzle_command(turb, body):
135 """Implementation of the `/puzzle edit` command
137 As dispatched from the puzzle() function.
140 channel_id = body['channel_id'][0]
141 trigger_id = body['trigger_id'][0]
143 puzzle = puzzle_for_channel(turb, channel_id)
146 return bot_reply("Sorry, this does not appear to be a puzzle channel.")
148 return edit_puzzle(turb, puzzle, trigger_id)
150 def edit_puzzle_button(turb, payload):
151 """Handler for the action of user pressing an edit_puzzle button"""
153 action_id = payload['actions'][0]['action_id']
154 trigger_id = payload['trigger_id']
156 (hunt_id, sort_key) = action_id.split('-', 1)
158 puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
161 return bot_reply("Error: Puzzle not found.")
163 return edit_puzzle(turb, puzzle, trigger_id)
165 actions['button']['edit_puzzle'] = edit_puzzle_button
167 def edit_puzzle(turb, puzzle, trigger_id):
168 """Common code for implementing an edit puzzle dialog
170 This implementation is common whether the edit operation was invoked
171 by a button (edit_puzzle_button) or a command (edit_puzzle_command).
174 round_options = hunt_rounds(turb, puzzle['hunt_id'])
176 if len(round_options):
177 round_options_block = [
178 multi_select_block("Round(s)", "rounds",
179 "Existing round(s) this puzzle belongs to",
181 initial_options=puzzle.get("rounds", None)),
184 round_options_block = []
187 if puzzle.get("status", "unsolved") == solved:
191 solution_list = puzzle.get("solution", [])
193 solution_str = ", ".join(solution_list)
197 "private_metadata": json.dumps({
198 "hunt_id": puzzle['hunt_id'],
200 "puzzle_id": puzzle['puzzle_id'],
201 "channel_id": puzzle["channel_id"],
202 "channel_url": puzzle["channel_url"],
203 "sheet_url": puzzle["sheet_url"],
205 "title": {"type": "plain_text", "text": "Edit Puzzle"},
206 "submit": { "type": "plain_text", "text": "Save" },
208 input_block("Puzzle name", "name", "Name of the puzzle",
209 initial_value=puzzle["name"]),
210 input_block("Puzzle URL", "url", "External URL of puzzle",
211 initial_value=puzzle.get("url", None),
213 checkbox_block("Is this a meta puzzle?", "Meta", "meta",
214 checked=(puzzle.get('type', 'plain') == 'meta')),
215 * round_options_block,
216 input_block("New round(s)", "new_rounds",
217 "New round(s) this puzzle belongs to " +
220 input_block("Tag(s)", "tags",
221 "Tags for this puzzle (comma separated)",
222 initial_value=", ".join(puzzle.get("tags", [])),
224 input_block("State", "state",
225 "State of this puzzle (partial progress, next steps)",
226 initial_value=puzzle.get("state", None),
229 "Puzzle status", "Solved", "solved",
230 checked=(puzzle.get('status', 'unsolved') == 'solved')),
231 input_block("Solution", "solution",
232 "Solution(s) (comma-separated if multiple)",
233 initial_value=solution_str,
238 result = turb.slack_client.views_open(trigger_id=trigger_id,
242 submission_handlers[result['view']['id']] = edit_puzzle_submission
246 def edit_puzzle_submission(turb, payload, metadata):
247 """Handler for the user submitting the edit puzzle modal
249 This is the modal view presented to the user by the edit_puzzle
255 # First, read all the various data from the request
256 meta = json.loads(metadata)
257 puzzle['hunt_id'] = meta['hunt_id']
258 puzzle['SK'] = meta['SK']
259 puzzle['puzzle_id'] = meta['puzzle_id']
260 puzzle['channel_id'] = meta['channel_id']
261 puzzle['channel_url'] = meta['channel_url']
262 puzzle['sheet_url'] = meta['sheet_url']
264 state = payload['view']['state']['values']
265 user_id = payload['user']['id']
267 puzzle['name'] = state['name']['name']['value']
268 url = state['url']['url']['value']
271 if state['meta']['meta']['selected_options']:
272 puzzle['type'] = 'meta'
274 puzzle['type'] = 'plain'
275 if 'rounds' in state:
276 rounds = [option['value'] for option in
277 state['rounds']['rounds']['selected_options']]
279 puzzle['rounds'] = rounds
280 new_rounds = state['new_rounds']['new_rounds']['value']
281 tags = state['tags']['tags']['value']
282 puzzle_state = state['state']['state']['value']
284 puzzle['state'] = puzzle_state
285 if state['solved']['solved']['selected_options']:
286 puzzle['status'] = 'solved'
288 puzzle['status'] = 'unsolved'
289 puzzle['solution'] = []
290 solution = state['solution']['solution']['value']
292 # Construct a list from a set to avoid any duplicates
293 puzzle['solution'] = list({
294 sol.strip() for sol in solution.split(',')
297 # Verify that there's a solution if the puzzle is mark solved
298 if puzzle['status'] == 'solved' and not puzzle['solution']:
299 return submission_error("solution",
300 "A solved puzzle requires a solution.")
302 if puzzle['status'] == 'unsolved' and puzzle['solution']:
303 return submission_error("solution",
304 "An unsolved puzzle should have no solution.")
306 # Add any new rounds to the database
308 if 'rounds' not in puzzle:
309 puzzle['rounds'] = []
310 for round in new_rounds.split(','):
311 # Drop any leading/trailing spaces from the round name
312 round = round.strip()
313 # Ignore any empty string
316 puzzle['rounds'].append(round)
319 'hunt_id': puzzle['hunt_id'],
320 'SK': 'round-' + round
327 for tag in tags.split(','):
328 # Drop any leading/trailing spaces from the tag
329 tag = tag.strip().upper()
330 # Ignore any empty string
333 # Reject a tag that is not alphabetic or underscore A-Z_
334 if not re.match(r'^[A-Z0-9_]*$', tag):
335 return submission_error(
337 "Error: Tags can only contain letters, numbers, "
338 + "and the underscore character."
340 puzzle['tags'].append(tag)
342 # Get old puzzle from the database (to determine what's changed)
343 old_puzzle = find_puzzle_for_sort_key(turb,
347 # If we are changing puzzle type (meta -> plain or plain -> meta)
348 # then the sort key has to change, so compute the new one and delete
349 # the old item from the database.
351 # XXX: We should really be using a transaction here to combine the
352 # delete_item and the put_item into a single transaction, but
353 # the boto interface is annoying in that transactions are only on
354 # the "Client" object which has a totally different interface than
355 # the "Table" object I've been using so I haven't figured out how
358 if puzzle['type'] != old_puzzle.get('type', 'plain'):
359 puzzle['SK'] = puzzle_sort_key(puzzle)
360 turb.table.delete_item(Key={
361 'hunt_id': old_puzzle['hunt_id'],
362 'SK': old_puzzle['SK']
365 # Update the puzzle in the database
366 turb.table.put_item(Item=puzzle)
368 # Inform the puzzle channel about the edit
369 edit_message = "Puzzle edited by <@{}>".format(user_id)
370 blocks = ([section_block(text_block(edit_message+":\n"))] +
371 puzzle_blocks(puzzle, include_rounds=True))
373 turb.slack_client, puzzle['channel_id'],
374 edit_message, blocks=blocks)
376 # Advertize any tag additions to the hunt
377 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
379 new_tags = set(puzzle['tags']) - set(old_puzzle['tags'])
381 message = "Puzzle <{}|{}> has been tagged: {}".format(
382 puzzle['channel_url'],
384 ", ".join(['`{}`'.format(t) for t in new_tags])
386 slack_send_message(turb.slack_client, hunt['channel_id'], message)
388 # Also inform the hunt if the puzzle's solved status changed
389 if puzzle['status'] != old_puzzle['status']:
390 if puzzle['status'] == 'solved':
391 message = "Puzzle <{}|{}> has been solved!".format(
392 puzzle['channel_url'],
395 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
396 puzzle['channel_url'],
398 slack_send_message(turb.slack_client, hunt['channel_id'], message)
400 # We need to set the channel topic if any of puzzle name, url,
401 # state, status, or solution, has changed. Let's just do that
402 # unconditionally here.
403 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
407 def edit_hunt_command(turb, body):
408 """Implementation of the `/hunt edit` command
410 As dispatched from the hunt() function.
413 channel_id = body['channel_id'][0]
414 trigger_id = body['trigger_id'][0]
416 hunt = hunt_for_channel(turb, channel_id)
419 return bot_reply("Sorry, this does not appear to be a hunt channel.")
421 return edit_hunt(turb, hunt, trigger_id)
423 def edit_hunt_button(turb, payload):
424 """Handler for the action of user pressing an edit_hunt button"""
426 hunt_id = payload['actions'][0]['action_id']
427 trigger_id = payload['trigger_id']
429 hunt = find_hunt_for_hunt_id(turb, hunt_id)
432 return bot_reply("Error: Hunt not found.")
434 return edit_hunt(turb, hunt, trigger_id)
436 actions['button']['edit_hunt'] = edit_hunt_button
438 def edit_hunt(turb, hunt, trigger_id):
439 """Common code for implementing an edit hunt dialog
441 This implementation is common whether the edit operation was invoked
442 by a button (edit_hunt_button) or a command (edit_hunt_command).
447 "private_metadata": json.dumps({
448 "hunt_id": hunt["hunt_id"],
450 "is_hunt": hunt["is_hunt"],
451 "channel_id": hunt["channel_id"],
452 "sheet_url": hunt["sheet_url"],
453 "folder_id": hunt["folder_id"],
455 "title": { "type": "plain_text", "text": "Edit Hunt" },
456 "submit": { "type": "plain_text", "text": "Save" },
458 input_block("Hunt name", "name", "Name of the hunt",
459 initial_value=hunt["name"]),
460 input_block("Hunt URL", "url", "External URL of hunt",
461 initial_value=hunt.get("url", None),
463 input_block("State", "state",
464 "State of the hunt (goals, upcoming meetings, etc.)",
465 initial_value=hunt.get("state", None),
467 checkbox_block("Is this hunt active?", "Active", "active",
468 checked=(hunt.get('active', False)))
472 result = turb.slack_client.views_open(trigger_id=trigger_id,
476 submission_handlers[result['view']['id']] = edit_hunt_submission
480 def edit_hunt_submission(turb, payload, metadata):
481 """Handler for the user submitting the edit hunt modal
483 This is the modal view presented by the edit_hunt function above.
488 # First, read all the various data from the request
489 meta = json.loads(metadata)
490 hunt['hunt_id'] = meta['hunt_id']
491 hunt['SK'] = meta['SK']
492 hunt['is_hunt'] = meta['is_hunt']
493 hunt['channel_id'] = meta['channel_id']
494 hunt['sheet_url'] = meta['sheet_url']
495 hunt['folder_id'] = meta['folder_id']
497 state = payload['view']['state']['values']
498 user_id = payload['user']['id']
500 hunt['name'] = state['name']['name']['value']
501 url = state['url']['url']['value']
505 hunt_state = state['state']['state']['value']
507 hunt['state'] = hunt_state
508 if state['active']['active']['selected_options']:
509 hunt['active'] = True
511 hunt['active'] = False
513 # Update the hunt in the database
514 turb.table.put_item(Item=hunt)
516 # Inform the hunt channel about the edit
517 edit_message = "Hunt edited by <@{}>".format(user_id)
519 section_block(text_block(edit_message)),
520 section_block(text_block("Hunt name: {}".format(hunt['name']))),
523 url = hunt.get('url', None)
526 section_block(text_block("Hunt URL: {}".format(hunt['url'])))
530 turb.slack_client, hunt['channel_id'],
531 edit_message, blocks=blocks)
533 # Update channel topic and description
534 hunt_update_topic(turb, hunt)
538 def new_hunt_command(turb, body):
539 """Implementation of the '/hunt new' command
541 As dispatched from the hunt() function.
544 trigger_id = body['trigger_id'][0]
546 return new_hunt(turb, trigger_id)
548 def new_hunt_button(turb, payload):
549 """Handler for the action of user pressing the new_hunt button"""
551 trigger_id = payload['trigger_id']
553 return new_hunt(turb, trigger_id)
555 def new_hunt(turb, trigger_id):
556 """Common code for implementing a new hunt dialog
558 This implementation is common whether the operations was invoked
559 by a button (new_hunt_button) or a command (new_hunt_command).
564 "private_metadata": json.dumps({}),
565 "title": { "type": "plain_text", "text": "New Hunt" },
566 "submit": { "type": "plain_text", "text": "Create" },
568 input_block("Hunt name", "name", "Name of the hunt"),
569 input_block("Hunt ID", "hunt_id",
570 "Used as puzzle channel prefix "
571 + "(no spaces nor punctuation)"),
572 input_block("Hunt URL", "url", "External URL of hunt",
577 result = turb.slack_client.views_open(trigger_id=trigger_id,
580 submission_handlers[result['view']['id']] = new_hunt_submission
584 actions['button']['new_hunt'] = new_hunt_button
586 def new_hunt_submission(turb, payload, metadata):
587 """Handler for the user submitting the new hunt modal
589 This is the modal view presented to the user by the new_hunt
592 state = payload['view']['state']['values']
593 user_id = payload['user']['id']
594 name = state['name']['name']['value']
595 hunt_id = state['hunt_id']['hunt_id']['value']
596 url = state['url']['url']['value']
598 # Validate that the hunt_id contains no invalid characters
599 if not re.match(valid_id_re, hunt_id):
600 return submission_error("hunt_id",
601 "Hunt ID can only contain lowercase letters, "
602 + "numbers, and underscores")
604 # Check to see if the turbot table exists
606 exists = turb.table.table_status in ("CREATING", "UPDATING",
611 # Create the turbot table if necessary.
613 turb.table = turb.db.create_table(
616 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
617 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
619 AttributeDefinitions=[
620 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
621 {'AttributeName': 'SK', 'AttributeType': 'S'},
622 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
623 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
624 {'AttributeName': 'url', 'AttributeType': 'S'},
625 {'AttributeName': 'puzzle_id', 'AttributeType': 'S'}
627 ProvisionedThroughput={
628 'ReadCapacityUnits': 5,
629 'WriteCapacityUnits': 5
631 GlobalSecondaryIndexes=[
633 'IndexName': 'channel_id_index',
635 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
638 'ProjectionType': 'ALL'
640 'ProvisionedThroughput': {
641 'ReadCapacityUnits': 5,
642 'WriteCapacityUnits': 5
646 'IndexName': 'is_hunt_index',
648 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
651 'ProjectionType': 'ALL'
653 'ProvisionedThroughput': {
654 'ReadCapacityUnits': 5,
655 'WriteCapacityUnits': 5
659 LocalSecondaryIndexes = [
661 'IndexName': 'url_index',
663 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
664 {'AttributeName': 'url', 'KeyType': 'RANGE'},
667 'ProjectionType': 'ALL'
671 'IndexName': 'puzzle_id_index',
673 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
674 {'AttributeName': 'puzzle_id', 'KeyType': 'RANGE'},
677 'ProjectionType': 'ALL'
682 return submission_error(
684 "Still bootstrapping turbot table. Try again in a minute, please.")
686 # Create a channel for the hunt
688 response = turb.slack_client.conversations_create(name=hunt_id)
689 except SlackApiError as e:
690 return submission_error("hunt_id",
691 "Error creating Slack channel: {}"
692 .format(e.response['error']))
694 channel_id = response['channel']['id']
696 # Insert the newly-created hunt into the database
697 # (leaving it as non-active for now until the channel-created handler
698 # finishes fixing it up with a sheet and a companion table)
701 "SK": "hunt-{}".format(hunt_id),
703 "channel_id": channel_id,
709 turb.table.put_item(Item=item)
711 # Update channel topic and description
712 hunt_update_topic(turb, item)
714 # Invite the initiating user to the channel
715 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
719 def view_submission(turb, payload):
720 """Handler for Slack interactive view submission
722 Specifically, those that have a payload type of 'view_submission'"""
724 view_id = payload['view']['id']
725 metadata = payload['view']['private_metadata']
727 if view_id in submission_handlers:
728 return submission_handlers[view_id](turb, payload, metadata)
730 print("Error: Unknown view ID: {}".format(view_id))
735 def rot(turb, body, args):
736 """Implementation of the /rot command
738 The args string should be as follows:
740 [count|*] String to be rotated
742 That is, the first word of the string is an optional number (or
743 the character '*'). If this is a number it indicates an amount to
744 rotate each character in the string. If the count is '*' or is not
745 present, then the string will be rotated through all possible 25
748 The result of the rotation is returned (with Slack formatting) in
749 the body of the response so that Slack will provide it as a reply
750 to the user who submitted the slash command."""
752 channel_name = body['channel_name'][0]
753 response_url = body['response_url'][0]
754 channel_id = body['channel_id'][0]
756 result = turbot.rot.rot(args)
758 if (channel_name == "directmessage"):
759 requests.post(response_url,
760 json = {"text": result},
761 headers = {"Content-type": "application/json"})
763 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
767 commands["/rot"] = rot
769 def get_table_item(turb, table_name, key, value):
770 """Get an item from the database 'table_name' with 'key' as 'value'
772 Returns a tuple of (item, table) if found and (None, None) otherwise."""
774 table = turb.db.Table(table_name)
776 response = table.get_item(Key={key: value})
778 if 'Item' in response:
779 return (response['Item'], table)
783 def db_entry_for_channel(turb, channel_id):
784 """Given a channel ID return the database item for this channel
786 If this channel is a registered hunt or puzzle channel, return the
787 corresponding row from the database for this channel. Otherwise,
790 Note: If you need to specifically ensure that the channel is a
791 puzzle or a hunt, please call puzzle_for_channel or
792 hunt_for_channel respectively.
795 response = turb.table.query(
796 IndexName = "channel_id_index",
797 KeyConditionExpression=Key("channel_id").eq(channel_id)
800 if response['Count'] == 0:
803 return response['Items'][0]
806 def puzzle_for_channel(turb, channel_id):
808 """Given a channel ID return the puzzle from the database for this channel
810 If the given channel_id is a puzzle's channel, this function
811 returns a dict filled with the attributes from the puzzle's entry
814 Otherwise, this function returns None.
817 entry = db_entry_for_channel(turb, channel_id)
819 if entry and entry['SK'].startswith('puzzle-'):
824 def hunt_for_channel(turb, channel_id):
826 """Given a channel ID return the hunt from the database for this channel
828 This works whether the original channel is a primary hunt channel,
829 or if it is one of the channels of a puzzle belonging to the hunt.
831 Returns None if channel does not belong to a hunt, otherwise a
832 dictionary with all fields from the hunt's row in the table,
833 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
836 entry = db_entry_for_channel(turb, channel_id)
838 # We're done if this channel doesn't exist in the database at all
842 # Also done if this channel is a hunt channel
843 if entry['SK'].startswith('hunt-'):
846 # Otherwise, (the channel is in the database, but is not a hunt),
847 # we expect this to be a puzzle channel instead
848 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
850 # python3.9 has a built-in removeprefix but AWS only has python3.8
851 def remove_prefix(text, prefix):
852 if text.startswith(prefix):
853 return text[len(prefix):]
856 def hunt_rounds(turb, hunt_id):
857 """Returns array of strings giving rounds that exist in the given hunt"""
859 response = turb.table.query(
860 KeyConditionExpression=(
861 Key('hunt_id').eq(hunt_id) &
862 Key('SK').begins_with('round-')
866 if response['Count'] == 0:
869 return [remove_prefix(option['SK'], 'round-')
870 for option in response['Items']]
872 def puzzle(turb, body, args):
873 """Implementation of the /puzzle command
875 The args string can be a sub-command:
877 /puzzle new: Bring up a dialog to create a new puzzle
879 /puzzle edit: Edit the puzzle for the current channel
881 Or with no argument at all:
883 /puzzle: Print details of the current puzzle (if in a puzzle channel)
887 return new_puzzle(turb, body)
890 return edit_puzzle_command(turb, body)
893 return bot_reply("Unknown syntax for `/puzzle` command. " +
894 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
895 "and `/puzzle new` to display, edit, or create " +
898 # For no arguments we print the current puzzle as a reply
899 channel_id = body['channel_id'][0]
900 response_url = body['response_url'][0]
902 puzzle = puzzle_for_channel(turb, channel_id)
905 hunt = hunt_for_channel(turb, channel_id)
908 "This is not a puzzle channel, but is a hunt channel. "
909 + "If you want to create a new puzzle for this hunt, use "
913 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
914 + "channel, so the `/puzzle` command cannot work here.")
916 blocks = puzzle_blocks(puzzle, include_rounds=True)
918 # For a meta puzzle, also display the titles and solutions for all
919 # puzzles in the same round.
920 if puzzle.get('type', 'plain') == 'meta':
921 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
923 # Drop this puzzle itself from the report
924 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
926 for round in puzzle.get('rounds', [None]):
927 answers = round_quoted_puzzles_titles_answers(round, puzzles)
929 section_block(text_block(
930 "*Feeder solutions from round {}*".format(
931 round if round else "<none>"
933 section_block(text_block(answers))
936 requests.post(response_url,
937 json = {'blocks': blocks},
938 headers = {'Content-type': 'application/json'}
943 commands["/puzzle"] = puzzle
945 def new(turb, body, args):
946 """Implementation of the `/new` command
948 This can be used to create a new hunt ("/new hunt") or a new
949 puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
950 default behavior (as it is much more common).
952 This operations are identical to the existing "/hunt new" and
953 "/puzzle new". I don't know that that redundancy is actually
954 helpful in the interface. But at least having both allows us to
955 experiment and decide which is more natural and should be kept
960 return new_hunt_command(turb, body)
962 return new_puzzle(turb, body)
964 commands["/new"] = new
966 def new_puzzle(turb, body):
967 """Implementation of the "/puzzle new" command
969 This brings up a dialog box for creating a new puzzle.
972 channel_id = body['channel_id'][0]
973 trigger_id = body['trigger_id'][0]
975 hunt = hunt_for_channel(turb, channel_id)
978 return bot_reply("Sorry, this channel doesn't appear to "
979 + "be a hunt or puzzle channel")
981 # We used puzzle (if available) to select the initial round(s)
982 puzzle = puzzle_for_channel(turb, channel_id)
983 initial_rounds = None
985 initial_rounds=puzzle.get("rounds", None)
987 round_options = hunt_rounds(turb, hunt['hunt_id'])
989 if len(round_options):
990 round_options_block = [
991 multi_select_block("Round(s)", "rounds",
992 "Existing round(s) this puzzle belongs to",
994 initial_options=initial_rounds)
997 round_options_block = []
1001 "private_metadata": json.dumps({
1002 "hunt_id": hunt['hunt_id'],
1004 "title": {"type": "plain_text", "text": "New Puzzle"},
1005 "submit": { "type": "plain_text", "text": "Create" },
1007 section_block(text_block("*For {}*".format(hunt['name']))),
1008 input_block("Puzzle name", "name", "Name of the puzzle"),
1009 input_block("Puzzle URL", "url", "External URL of puzzle",
1011 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
1012 * round_options_block,
1013 input_block("New round(s)", "new_rounds",
1014 "New round(s) this puzzle belongs to " +
1015 "(comma separated)",
1017 input_block("Tag(s)", "tags",
1018 "Tags for this puzzle (comma separated)",
1023 result = turb.slack_client.views_open(trigger_id=trigger_id,
1027 submission_handlers[result['view']['id']] = new_puzzle_submission
1031 def new_puzzle_submission(turb, payload, metadata):
1032 """Handler for the user submitting the new puzzle modal
1034 This is the modal view presented to the user by the new_puzzle
1038 # First, read all the various data from the request
1039 meta = json.loads(metadata)
1040 hunt_id = meta['hunt_id']
1042 state = payload['view']['state']['values']
1044 # And start loading data into a puzzle dict
1046 puzzle['hunt_id'] = hunt_id
1047 puzzle['name'] = state['name']['name']['value']
1048 url = state['url']['url']['value']
1051 if state['meta']['meta']['selected_options']:
1052 puzzle['type'] = 'meta'
1054 puzzle['type'] = 'plain'
1055 if 'rounds' in state:
1056 rounds = [option['value'] for option in
1057 state['rounds']['rounds']['selected_options']]
1060 new_rounds = state['new_rounds']['new_rounds']['value']
1061 tags = state['tags']['tags']['value']
1063 # Create a Slack-channel-safe puzzle_id
1064 puzzle['puzzle_id'] = puzzle_id_from_name(puzzle['name'])
1066 # Before doing anything, reject this puzzle if a puzzle already
1067 # exists with the same puzzle_id or url
1068 existing = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle['puzzle_id'])
1070 return submission_error(
1072 "Error: This name collides with an existing puzzle.")
1075 existing = find_puzzle_for_url(turb, hunt_id, url)
1077 return submission_error(
1079 "Error: A puzzle with this URL already exists.")
1081 # Add any new rounds to the database
1083 for round in new_rounds.split(','):
1084 # Drop any leading/trailing spaces from the round name
1085 round = round.strip()
1086 # Ignore any empty string
1089 rounds.append(round)
1090 turb.table.put_item(
1093 'SK': 'round-' + round
1100 for tag in tags.split(','):
1101 # Drop any leading/trailing spaces from the tag
1102 tag = tag.strip().upper()
1103 # Ignore any empty string
1106 # Reject a tag that is not alphabetic or underscore A-Z_
1107 if not re.match(r'^[A-Z0-9_]*$', tag):
1108 return submission_error(
1110 "Error: Tags can only contain letters, numbers, "
1111 + "and the underscore character."
1113 puzzle['tags'].append(tag)
1116 puzzle['rounds'] = rounds
1118 puzzle['solution'] = []
1119 puzzle['status'] = 'unsolved'
1121 # Create a channel for the puzzle
1122 channel_name = puzzle_channel_name(puzzle)
1125 response = turb.slack_client.conversations_create(
1127 except SlackApiError as e:
1128 return submission_error(
1130 "Error creating Slack channel {}: {}"
1131 .format(channel_name, e.response['error']))
1133 puzzle['channel_id'] = response['channel']['id']
1135 # Finally, compute the appropriate sort key
1136 puzzle["SK"] = puzzle_sort_key(puzzle)
1138 # Insert the newly-created puzzle into the database
1139 turb.table.put_item(Item=puzzle)
1143 def state(turb, body, args):
1144 """Implementation of the /state command
1146 The args string should be a brief sentence describing where things
1147 stand or what's needed."""
1149 channel_id = body['channel_id'][0]
1151 old_puzzle = puzzle_for_channel(turb, channel_id)
1155 "Sorry, the /state command only works in a puzzle channel")
1157 # Make a deep copy of the puzzle object
1158 puzzle = puzzle_copy(old_puzzle)
1160 # Update the puzzle in the database
1161 puzzle['state'] = args
1162 turb.table.put_item(Item=puzzle)
1164 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1168 commands["/state"] = state
1170 def tag(turb, body, args):
1171 """Implementation of the `/tag` command.
1173 Arg is either a tag to add (optionally prefixed with '+'), or if
1174 prefixed with '-' is a tag to remove.
1178 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1179 + "or `/tag -TAG_TO_REMOVE`.")
1181 channel_id = body['channel_id'][0]
1183 old_puzzle = puzzle_for_channel(turb, channel_id)
1187 "Sorry, the /tag command only works in a puzzle channel")
1198 # Force tag to all uppercase
1201 # Reject a tag that is not alphabetic or underscore A-Z_
1202 if not re.match(r'^[A-Z0-9_]*$', tag):
1203 return bot_reply("Sorry, tags can only contain letters, numbers, "
1204 + "and the underscore character.")
1206 if action == 'remove':
1207 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1208 return bot_reply("Nothing to do. This puzzle is not tagged "
1209 + "with the tag: {}".format(tag))
1211 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1212 return bot_reply("Nothing to do. This puzzle is already tagged "
1213 + "with the tag: {}".format(tag))
1215 # OK. Error checking is done. Let's get to work
1217 # Make a deep copy of the puzzle object
1218 puzzle = puzzle_copy(old_puzzle)
1220 if action == 'remove':
1221 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1223 if 'tags' not in puzzle:
1224 puzzle['tags'] = [tag]
1226 puzzle['tags'].append(tag)
1228 turb.table.put_item(Item=puzzle)
1230 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1232 # Advertize any tag additions to the hunt
1233 new_tags = set(puzzle['tags']) - set(old_puzzle['tags'])
1235 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1236 message = "Puzzle <{}|{}> has been tagged: {}".format(
1237 puzzle['channel_url'],
1239 ", ".join(['`{}`'.format(t) for t in new_tags])
1241 slack_send_message(turb.slack_client, hunt['channel_id'], message)
1245 commands["/tag"] = tag
1247 def solved(turb, body, args):
1248 """Implementation of the /solved command
1250 The args string should be a confirmed solution."""
1252 channel_id = body['channel_id'][0]
1253 user_id = body['user_id'][0]
1255 old_puzzle = puzzle_for_channel(turb, channel_id)
1258 return bot_reply("Sorry, this is not a puzzle channel.")
1262 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1264 # Make a deep copy of the puzzle object
1265 puzzle = puzzle_copy(old_puzzle)
1267 # Set the status and solution fields in the database
1268 puzzle['status'] = 'solved'
1270 # Don't append a duplicate solution
1271 if args not in puzzle['solution']:
1272 puzzle['solution'].append(args)
1273 if 'state' in puzzle:
1275 turb.table.put_item(Item=puzzle)
1277 # Report the solution to the puzzle's channel
1279 turb.slack_client, channel_id,
1280 "Puzzle marked solved by <@{}>: `{}`".format(user_id, args))
1282 # Also report the solution to the hunt channel
1283 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1285 turb.slack_client, hunt['channel_id'],
1286 "Puzzle <{}|{}> has been solved!".format(
1287 puzzle['channel_url'],
1291 # And update the puzzle's description
1292 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1296 commands["/solved"] = solved
1298 def delete(turb, body, args):
1299 """Implementation of the /delete command
1301 The argument to this command is the ID of a hunt.
1303 The command will report an error if the specified hunt is active.
1305 If the hunt is inactive, this command will archive all channels
1310 return bot_reply("Error, no hunt provided. Usage: `/delete HUNT_ID`")
1313 hunt = find_hunt_for_hunt_id(turb, hunt_id)
1316 return bot_reply("Error, no hunt named \"{}\" exists.".format(hunt_id))
1320 "Error, refusing to delete active hunt \"{}\".".format(hunt_id)
1323 if hunt['hunt_id'] != hunt_id:
1325 "Error, expected hunt ID of \"{}\" but found \"{}\".".format(
1326 hunt_id, hunt['hunt_id']
1330 puzzles = hunt_puzzles_for_hunt_id(turb, hunt_id)
1332 for puzzle in puzzles:
1333 channel_id = puzzle['channel_id']
1334 turb.slack_client.conversations_archive(channel=channel_id)
1336 turb.slack_client.conversations_archive(channel=hunt['channel_id'])
1340 commands["/delete"] = delete
1342 def hunt(turb, body, args):
1343 """Implementation of the /hunt command
1345 The (optional) args string can be used to filter which puzzles to
1346 display. The first word can be one of 'all', 'unsolved', or
1347 'solved' and can be used to display only puzzles with the given
1348 status. If this first word is missing, this command will display
1349 only unsolved puzzles by default.
1351 Any remaining text in the args string will be interpreted as
1352 search terms. These will be split into separate terms on space
1353 characters, (though quotation marks can be used to include a space
1354 character in a term). All terms must match on a puzzle in order
1355 for that puzzle to be included. But a puzzle will be considered to
1356 match if any of the puzzle title, round title, puzzle URL, puzzle
1357 state, puzzle type, tags, or puzzle solution match. Matching will
1358 be performed without regard to case sensitivity and the search
1359 terms can include regular expression syntax.
1363 channel_id = body['channel_id'][0]
1364 response_url = body['response_url'][0]
1366 # First, farm off "/hunt new" and "/hunt edit" a separate commands
1368 return new_hunt_command(turb, body)
1371 return edit_hunt_command(turb, body)
1375 # The first word can be a puzzle status and all remaining word
1376 # (if any) are search terms. _But_, if the first word is not a
1377 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1378 # words are search terms and we default status to 'unsolved'.
1379 split_args = args.split(' ', 1)
1380 status = split_args[0]
1381 if (len(split_args) > 1):
1382 terms = split_args[1]
1383 if status not in ('unsolved', 'solved', 'all'):
1389 # Separate search terms on spaces (but allow for quotation marks
1390 # to capture spaces in a search term)
1392 terms = shlex.split(terms)
1394 hunt = hunt_for_channel(turb, channel_id)
1397 return bot_reply("Sorry, this channel doesn't appear to "
1398 + "be a hunt or puzzle channel")
1400 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1402 for block in blocks:
1403 if len(block) > 100:
1405 requests.post(response_url,
1406 json = { 'blocks': block },
1407 headers = {'Content-type': 'application/json'}
1412 commands["/hunt"] = hunt
1414 def round(turb, body, args):
1415 """Implementation of the /round command
1417 Displays puzzles in the same round(s) as the puzzle for the
1420 The (optional) args string can be used to filter which puzzles to
1421 display. The first word can be one of 'all', 'unsolved', or
1422 'solved' and can be used to display only puzzles with the given
1423 status. If this first word is missing, this command will display
1424 all puzzles in the round by default.
1426 Any remaining text in the args string will be interpreted as
1427 search terms. These will be split into separate terms on space
1428 characters, (though quotation marks can be used to include a space
1429 character in a term). All terms must match on a puzzle in order
1430 for that puzzle to be included. But a puzzle will be considered to
1431 match if any of the puzzle title, round title, puzzle URL, puzzle
1432 state, or puzzle solution match. Matching will be performed
1433 without regard to case sensitivity and the search terms can
1434 include regular expression syntax.
1437 channel_id = body['channel_id'][0]
1438 response_url = body['response_url'][0]
1440 puzzle = puzzle_for_channel(turb, channel_id)
1441 hunt = hunt_for_channel(turb, channel_id)
1446 "This is not a puzzle channel, but is a hunt channel. "
1447 + "Use /hunt if you want to see all rounds for this hunt.")
1450 "Sorry, this channel doesn't appear to be a puzzle channel "
1451 + "so the `/round` command cannot work here.")
1455 # The first word can be a puzzle status and all remaining word
1456 # (if any) are search terms. _But_, if the first word is not a
1457 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1458 # words are search terms and we default status to 'unsolved'.
1459 split_args = args.split(' ', 1)
1460 status = split_args[0]
1461 if (len(split_args) > 1):
1462 terms = split_args[1]
1463 if status not in ('unsolved', 'solved', 'all'):
1469 # Separate search terms on spaces (but allow for quotation marks
1470 # to capture spaces in a search term)
1472 terms = shlex.split(terms)
1474 blocks = hunt_blocks(turb, hunt,
1475 puzzle_status=status, search_terms=terms,
1476 limit_to_rounds=puzzle.get('rounds', [])
1479 for block in blocks:
1480 if len(block) > 100:
1482 requests.post(response_url,
1483 json = { 'blocks': block },
1484 headers = {'Content-type': 'application/json'}
1489 commands["/round"] = round
1491 def help_command(turb, body, args):
1492 """Implementation of the /help command
1494 Displays help on how to use Turbot.
1497 channel_id = body['channel_id'][0]
1498 response_url = body['response_url'][0]
1499 user_id = body['user_id'][0]
1501 # Process "/help me" first. It calls out to have_you_tried rather
1502 # than going through our help system.
1504 # Also, it reports in the current channel, (where all other help
1505 # output is reported privately to the invoking user).
1507 to_try = "In response to <@{}> asking `/help me`:\n\n{}\n".format(
1508 user_id, have_you_tried())
1510 # We'll try first to reply directly to the channel (for the benefit
1511 # of anyone else in the same channel that might be stuck too.
1513 # But if this doesn't work, (direct message or private channel),
1514 # then we can instead reply with an ephemeral message by using
1517 turb.slack_client.chat_postMessage(
1518 channel=channel_id, text=to_try)
1519 except SlackApiError:
1520 requests.post(response_url,
1521 json = {"text": to_try},
1522 headers = {"Content-type": "application/json"})
1525 help_string = turbot_help(args)
1527 requests.post(response_url,
1528 json = {"text": help_string},
1529 headers = {"Content-type": "application/json"})
1533 commands["/help"] = help_command