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 # Invite the initiating user to the channel
712 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
716 def view_submission(turb, payload):
717 """Handler for Slack interactive view submission
719 Specifically, those that have a payload type of 'view_submission'"""
721 view_id = payload['view']['id']
722 metadata = payload['view']['private_metadata']
724 if view_id in submission_handlers:
725 return submission_handlers[view_id](turb, payload, metadata)
727 print("Error: Unknown view ID: {}".format(view_id))
732 def rot(turb, body, args):
733 """Implementation of the /rot command
735 The args string should be as follows:
737 [count|*] String to be rotated
739 That is, the first word of the string is an optional number (or
740 the character '*'). If this is a number it indicates an amount to
741 rotate each character in the string. If the count is '*' or is not
742 present, then the string will be rotated through all possible 25
745 The result of the rotation is returned (with Slack formatting) in
746 the body of the response so that Slack will provide it as a reply
747 to the user who submitted the slash command."""
749 channel_name = body['channel_name'][0]
750 response_url = body['response_url'][0]
751 channel_id = body['channel_id'][0]
753 result = turbot.rot.rot(args)
755 if (channel_name == "directmessage"):
756 requests.post(response_url,
757 json = {"text": result},
758 headers = {"Content-type": "application/json"})
760 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
764 commands["/rot"] = rot
766 def get_table_item(turb, table_name, key, value):
767 """Get an item from the database 'table_name' with 'key' as 'value'
769 Returns a tuple of (item, table) if found and (None, None) otherwise."""
771 table = turb.db.Table(table_name)
773 response = table.get_item(Key={key: value})
775 if 'Item' in response:
776 return (response['Item'], table)
780 def db_entry_for_channel(turb, channel_id):
781 """Given a channel ID return the database item for this channel
783 If this channel is a registered hunt or puzzle channel, return the
784 corresponding row from the database for this channel. Otherwise,
787 Note: If you need to specifically ensure that the channel is a
788 puzzle or a hunt, please call puzzle_for_channel or
789 hunt_for_channel respectively.
792 response = turb.table.query(
793 IndexName = "channel_id_index",
794 KeyConditionExpression=Key("channel_id").eq(channel_id)
797 if response['Count'] == 0:
800 return response['Items'][0]
803 def puzzle_for_channel(turb, channel_id):
805 """Given a channel ID return the puzzle from the database for this channel
807 If the given channel_id is a puzzle's channel, this function
808 returns a dict filled with the attributes from the puzzle's entry
811 Otherwise, this function returns None.
814 entry = db_entry_for_channel(turb, channel_id)
816 if entry and entry['SK'].startswith('puzzle-'):
821 def hunt_for_channel(turb, channel_id):
823 """Given a channel ID return the hunt from the database for this channel
825 This works whether the original channel is a primary hunt channel,
826 or if it is one of the channels of a puzzle belonging to the hunt.
828 Returns None if channel does not belong to a hunt, otherwise a
829 dictionary with all fields from the hunt's row in the table,
830 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
833 entry = db_entry_for_channel(turb, channel_id)
835 # We're done if this channel doesn't exist in the database at all
839 # Also done if this channel is a hunt channel
840 if entry['SK'].startswith('hunt-'):
843 # Otherwise, (the channel is in the database, but is not a hunt),
844 # we expect this to be a puzzle channel instead
845 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
847 # python3.9 has a built-in removeprefix but AWS only has python3.8
848 def remove_prefix(text, prefix):
849 if text.startswith(prefix):
850 return text[len(prefix):]
853 def hunt_rounds(turb, hunt_id):
854 """Returns array of strings giving rounds that exist in the given hunt"""
856 response = turb.table.query(
857 KeyConditionExpression=(
858 Key('hunt_id').eq(hunt_id) &
859 Key('SK').begins_with('round-')
863 if response['Count'] == 0:
866 return [remove_prefix(option['SK'], 'round-')
867 for option in response['Items']]
869 def puzzle(turb, body, args):
870 """Implementation of the /puzzle command
872 The args string can be a sub-command:
874 /puzzle new: Bring up a dialog to create a new puzzle
876 /puzzle edit: Edit the puzzle for the current channel
878 Or with no argument at all:
880 /puzzle: Print details of the current puzzle (if in a puzzle channel)
884 return new_puzzle(turb, body)
887 return edit_puzzle_command(turb, body)
890 return bot_reply("Unknown syntax for `/puzzle` command. " +
891 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
892 "and `/puzzle new` to display, edit, or create " +
895 # For no arguments we print the current puzzle as a reply
896 channel_id = body['channel_id'][0]
897 response_url = body['response_url'][0]
899 puzzle = puzzle_for_channel(turb, channel_id)
902 hunt = hunt_for_channel(turb, channel_id)
905 "This is not a puzzle channel, but is a hunt channel. "
906 + "If you want to create a new puzzle for this hunt, use "
910 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
911 + "channel, so the `/puzzle` command cannot work here.")
913 blocks = puzzle_blocks(puzzle, include_rounds=True)
915 # For a meta puzzle, also display the titles and solutions for all
916 # puzzles in the same round.
917 if puzzle.get('type', 'plain') == 'meta':
918 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
920 # Drop this puzzle itself from the report
921 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
923 for round in puzzle.get('rounds', [None]):
924 answers = round_quoted_puzzles_titles_answers(round, puzzles)
926 section_block(text_block(
927 "*Feeder solutions from round {}*".format(
928 round if round else "<none>"
930 section_block(text_block(answers))
933 requests.post(response_url,
934 json = {'blocks': blocks},
935 headers = {'Content-type': 'application/json'}
940 commands["/puzzle"] = puzzle
942 def new(turb, body, args):
943 """Implementation of the `/new` command
945 This can be used to create a new hunt ("/new hunt") or a new
946 puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
947 default behavior (as it is much more common).
949 This operations are identical to the existing "/hunt new" and
950 "/puzzle new". I don't know that that redundancy is actually
951 helpful in the interface. But at least having both allows us to
952 experiment and decide which is more natural and should be kept
957 return new_hunt_command(turb, body)
959 return new_puzzle(turb, body)
961 commands["/new"] = new
963 def new_puzzle(turb, body):
964 """Implementation of the "/puzzle new" command
966 This brings up a dialog box for creating a new puzzle.
969 channel_id = body['channel_id'][0]
970 trigger_id = body['trigger_id'][0]
972 hunt = hunt_for_channel(turb, channel_id)
975 return bot_reply("Sorry, this channel doesn't appear to "
976 + "be a hunt or puzzle channel")
978 # We used puzzle (if available) to select the initial round(s)
979 puzzle = puzzle_for_channel(turb, channel_id)
980 initial_rounds = None
982 initial_rounds=puzzle.get("rounds", None)
984 round_options = hunt_rounds(turb, hunt['hunt_id'])
986 if len(round_options):
987 round_options_block = [
988 multi_select_block("Round(s)", "rounds",
989 "Existing round(s) this puzzle belongs to",
991 initial_options=initial_rounds)
994 round_options_block = []
998 "private_metadata": json.dumps({
999 "hunt_id": hunt['hunt_id'],
1001 "title": {"type": "plain_text", "text": "New Puzzle"},
1002 "submit": { "type": "plain_text", "text": "Create" },
1004 section_block(text_block("*For {}*".format(hunt['name']))),
1005 input_block("Puzzle name", "name", "Name of the puzzle"),
1006 input_block("Puzzle URL", "url", "External URL of puzzle",
1008 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
1009 * round_options_block,
1010 input_block("New round(s)", "new_rounds",
1011 "New round(s) this puzzle belongs to " +
1012 "(comma separated)",
1014 input_block("Tag(s)", "tags",
1015 "Tags for this puzzle (comma separated)",
1020 result = turb.slack_client.views_open(trigger_id=trigger_id,
1024 submission_handlers[result['view']['id']] = new_puzzle_submission
1028 def new_puzzle_submission(turb, payload, metadata):
1029 """Handler for the user submitting the new puzzle modal
1031 This is the modal view presented to the user by the new_puzzle
1035 # First, read all the various data from the request
1036 meta = json.loads(metadata)
1037 hunt_id = meta['hunt_id']
1039 state = payload['view']['state']['values']
1041 # And start loading data into a puzzle dict
1043 puzzle['hunt_id'] = hunt_id
1044 puzzle['name'] = state['name']['name']['value']
1045 url = state['url']['url']['value']
1048 if state['meta']['meta']['selected_options']:
1049 puzzle['type'] = 'meta'
1051 puzzle['type'] = 'plain'
1052 if 'rounds' in state:
1053 rounds = [option['value'] for option in
1054 state['rounds']['rounds']['selected_options']]
1057 new_rounds = state['new_rounds']['new_rounds']['value']
1058 tags = state['tags']['tags']['value']
1060 # Create a Slack-channel-safe puzzle_id
1061 puzzle['puzzle_id'] = puzzle_id_from_name(puzzle['name'])
1063 # Before doing anything, reject this puzzle if a puzzle already
1064 # exists with the same puzzle_id or url
1065 existing = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle['puzzle_id'])
1067 return submission_error(
1069 "Error: This name collides with an existing puzzle.")
1072 existing = find_puzzle_for_url(turb, hunt_id, url)
1074 return submission_error(
1076 "Error: A puzzle with this URL already exists.")
1078 # Add any new rounds to the database
1080 for round in new_rounds.split(','):
1081 # Drop any leading/trailing spaces from the round name
1082 round = round.strip()
1083 # Ignore any empty string
1086 rounds.append(round)
1087 turb.table.put_item(
1090 'SK': 'round-' + round
1097 for tag in tags.split(','):
1098 # Drop any leading/trailing spaces from the tag
1099 tag = tag.strip().upper()
1100 # Ignore any empty string
1103 # Reject a tag that is not alphabetic or underscore A-Z_
1104 if not re.match(r'^[A-Z0-9_]*$', tag):
1105 return submission_error(
1107 "Error: Tags can only contain letters, numbers, "
1108 + "and the underscore character."
1110 puzzle['tags'].append(tag)
1113 puzzle['rounds'] = rounds
1115 puzzle['solution'] = []
1116 puzzle['status'] = 'unsolved'
1118 # Create a channel for the puzzle
1119 channel_name = puzzle_channel_name(puzzle)
1122 response = turb.slack_client.conversations_create(
1124 except SlackApiError as e:
1125 return submission_error(
1127 "Error creating Slack channel {}: {}"
1128 .format(channel_name, e.response['error']))
1130 puzzle['channel_id'] = response['channel']['id']
1132 # Finally, compute the appropriate sort key
1133 puzzle["SK"] = puzzle_sort_key(puzzle)
1135 # Insert the newly-created puzzle into the database
1136 turb.table.put_item(Item=puzzle)
1140 def state(turb, body, args):
1141 """Implementation of the /state command
1143 The args string should be a brief sentence describing where things
1144 stand or what's needed."""
1146 channel_id = body['channel_id'][0]
1148 old_puzzle = puzzle_for_channel(turb, channel_id)
1152 "Sorry, the /state command only works in a puzzle channel")
1154 # Make a deep copy of the puzzle object
1155 puzzle = puzzle_copy(old_puzzle)
1157 # Update the puzzle in the database
1158 puzzle['state'] = args
1159 turb.table.put_item(Item=puzzle)
1161 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1165 commands["/state"] = state
1167 def tag(turb, body, args):
1168 """Implementation of the `/tag` command.
1170 Arg is either a tag to add (optionally prefixed with '+'), or if
1171 prefixed with '-' is a tag to remove.
1175 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1176 + "or `/tag -TAG_TO_REMOVE`.")
1178 channel_id = body['channel_id'][0]
1180 old_puzzle = puzzle_for_channel(turb, channel_id)
1184 "Sorry, the /tag command only works in a puzzle channel")
1195 # Force tag to all uppercase
1198 # Reject a tag that is not alphabetic or underscore A-Z_
1199 if not re.match(r'^[A-Z0-9_]*$', tag):
1200 return bot_reply("Sorry, tags can only contain letters, numbers, "
1201 + "and the underscore character.")
1203 if action == 'remove':
1204 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1205 return bot_reply("Nothing to do. This puzzle is not tagged "
1206 + "with the tag: {}".format(tag))
1208 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1209 return bot_reply("Nothing to do. This puzzle is already tagged "
1210 + "with the tag: {}".format(tag))
1212 # OK. Error checking is done. Let's get to work
1214 # Make a deep copy of the puzzle object
1215 puzzle = puzzle_copy(old_puzzle)
1217 if action == 'remove':
1218 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1220 if 'tags' not in puzzle:
1221 puzzle['tags'] = [tag]
1223 puzzle['tags'].append(tag)
1225 turb.table.put_item(Item=puzzle)
1227 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1229 # Advertize any tag additions to the hunt
1230 new_tags = set(puzzle['tags']) - set(old_puzzle['tags'])
1232 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1233 message = "Puzzle <{}|{}> has been tagged: {}".format(
1234 puzzle['channel_url'],
1236 ", ".join(['`{}`'.format(t) for t in new_tags])
1238 slack_send_message(turb.slack_client, hunt['channel_id'], message)
1242 commands["/tag"] = tag
1244 def solved(turb, body, args):
1245 """Implementation of the /solved command
1247 The args string should be a confirmed solution."""
1249 channel_id = body['channel_id'][0]
1250 user_id = body['user_id'][0]
1252 old_puzzle = puzzle_for_channel(turb, channel_id)
1255 return bot_reply("Sorry, this is not a puzzle channel.")
1259 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1261 # Make a deep copy of the puzzle object
1262 puzzle = puzzle_copy(old_puzzle)
1264 # Set the status and solution fields in the database
1265 puzzle['status'] = 'solved'
1267 # Don't append a duplicate solution
1268 if args not in puzzle['solution']:
1269 puzzle['solution'].append(args)
1270 if 'state' in puzzle:
1272 turb.table.put_item(Item=puzzle)
1274 # Report the solution to the puzzle's channel
1276 turb.slack_client, channel_id,
1277 "Puzzle marked solved by <@{}>: `{}`".format(user_id, args))
1279 # Also report the solution to the hunt channel
1280 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1282 turb.slack_client, hunt['channel_id'],
1283 "Puzzle <{}|{}> has been solved!".format(
1284 puzzle['channel_url'],
1288 # And update the puzzle's description
1289 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1293 commands["/solved"] = solved
1295 def delete(turb, body, args):
1296 """Implementation of the /delete command
1298 The argument to this command is the ID of a hunt.
1300 The command will report an error if the specified hunt is active.
1302 If the hunt is inactive, this command will archive all channels
1307 return bot_reply("Error, no hunt provided. Usage: `/delete HUNT_ID`")
1310 hunt = find_hunt_for_hunt_id(turb, hunt_id)
1313 return bot_reply("Error, no hunt named \"{}\" exists.".format(hunt_id))
1317 "Error, refusing to delete active hunt \"{}\".".format(hunt_id)
1320 if hunt['hunt_id'] != hunt_id:
1322 "Error, expected hunt ID of \"{}\" but found \"{}\".".format(
1323 hunt_id, hunt['hunt_id']
1327 puzzles = hunt_puzzles_for_hunt_id(turb, hunt_id)
1329 for puzzle in puzzles:
1330 channel_id = puzzle['channel_id']
1331 turb.slack_client.conversations_archive(channel=channel_id)
1333 turb.slack_client.conversations_archive(channel=hunt['channel_id'])
1337 commands["/delete"] = delete
1339 def hunt(turb, body, args):
1340 """Implementation of the /hunt command
1342 The (optional) args string can be used to filter which puzzles to
1343 display. The first word can be one of 'all', 'unsolved', or
1344 'solved' and can be used to display only puzzles with the given
1345 status. If this first word is missing, this command will display
1346 only unsolved puzzles by default.
1348 Any remaining text in the args string will be interpreted as
1349 search terms. These will be split into separate terms on space
1350 characters, (though quotation marks can be used to include a space
1351 character in a term). All terms must match on a puzzle in order
1352 for that puzzle to be included. But a puzzle will be considered to
1353 match if any of the puzzle title, round title, puzzle URL, puzzle
1354 state, puzzle type, tags, or puzzle solution match. Matching will
1355 be performed without regard to case sensitivity and the search
1356 terms can include regular expression syntax.
1360 channel_id = body['channel_id'][0]
1361 response_url = body['response_url'][0]
1363 # First, farm off "/hunt new" and "/hunt edit" a separate commands
1365 return new_hunt_command(turb, body)
1368 return edit_hunt_command(turb, body)
1372 # The first word can be a puzzle status and all remaining word
1373 # (if any) are search terms. _But_, if the first word is not a
1374 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1375 # words are search terms and we default status to 'unsolved'.
1376 split_args = args.split(' ', 1)
1377 status = split_args[0]
1378 if (len(split_args) > 1):
1379 terms = split_args[1]
1380 if status not in ('unsolved', 'solved', 'all'):
1386 # Separate search terms on spaces (but allow for quotation marks
1387 # to capture spaces in a search term)
1389 terms = shlex.split(terms)
1391 hunt = hunt_for_channel(turb, channel_id)
1394 return bot_reply("Sorry, this channel doesn't appear to "
1395 + "be a hunt or puzzle channel")
1397 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1399 for block in blocks:
1400 if len(block) > 100:
1402 requests.post(response_url,
1403 json = { 'blocks': block },
1404 headers = {'Content-type': 'application/json'}
1409 commands["/hunt"] = hunt
1411 def round(turb, body, args):
1412 """Implementation of the /round command
1414 Displays puzzles in the same round(s) as the puzzle for the
1417 The (optional) args string can be used to filter which puzzles to
1418 display. The first word can be one of 'all', 'unsolved', or
1419 'solved' and can be used to display only puzzles with the given
1420 status. If this first word is missing, this command will display
1421 all puzzles in the round by default.
1423 Any remaining text in the args string will be interpreted as
1424 search terms. These will be split into separate terms on space
1425 characters, (though quotation marks can be used to include a space
1426 character in a term). All terms must match on a puzzle in order
1427 for that puzzle to be included. But a puzzle will be considered to
1428 match if any of the puzzle title, round title, puzzle URL, puzzle
1429 state, or puzzle solution match. Matching will be performed
1430 without regard to case sensitivity and the search terms can
1431 include regular expression syntax.
1434 channel_id = body['channel_id'][0]
1435 response_url = body['response_url'][0]
1437 puzzle = puzzle_for_channel(turb, channel_id)
1438 hunt = hunt_for_channel(turb, channel_id)
1443 "This is not a puzzle channel, but is a hunt channel. "
1444 + "Use /hunt if you want to see all rounds for this hunt.")
1447 "Sorry, this channel doesn't appear to be a puzzle channel "
1448 + "so the `/round` command cannot work here.")
1452 # The first word can be a puzzle status and all remaining word
1453 # (if any) are search terms. _But_, if the first word is not a
1454 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1455 # words are search terms and we default status to 'unsolved'.
1456 split_args = args.split(' ', 1)
1457 status = split_args[0]
1458 if (len(split_args) > 1):
1459 terms = split_args[1]
1460 if status not in ('unsolved', 'solved', 'all'):
1466 # Separate search terms on spaces (but allow for quotation marks
1467 # to capture spaces in a search term)
1469 terms = shlex.split(terms)
1471 blocks = hunt_blocks(turb, hunt,
1472 puzzle_status=status, search_terms=terms,
1473 limit_to_rounds=puzzle.get('rounds', [])
1476 for block in blocks:
1477 if len(block) > 100:
1479 requests.post(response_url,
1480 json = { 'blocks': block },
1481 headers = {'Content-type': 'application/json'}
1486 commands["/round"] = round
1488 def help_command(turb, body, args):
1489 """Implementation of the /help command
1491 Displays help on how to use Turbot.
1494 channel_id = body['channel_id'][0]
1495 response_url = body['response_url'][0]
1496 user_id = body['user_id'][0]
1498 # Process "/help me" first. It calls out to have_you_tried rather
1499 # than going through our help system.
1501 # Also, it reports in the current channel, (where all other help
1502 # output is reported privately to the invoking user).
1504 to_try = "In response to <@{}> asking `/help me`:\n\n{}\n".format(
1505 user_id, have_you_tried())
1507 # We'll try first to reply directly to the channel (for the benefit
1508 # of anyone else in the same channel that might be stuck too.
1510 # But if this doesn't work, (direct message or private channel),
1511 # then we can instead reply with an ephemeral message by using
1514 turb.slack_client.chat_postMessage(
1515 channel=channel_id, text=to_try)
1516 except SlackApiError:
1517 requests.post(response_url,
1518 json = {"text": to_try},
1519 headers = {"Content-type": "application/json"})
1522 help_string = turbot_help(args)
1524 requests.post(response_url,
1525 json = {"text": help_string},
1526 headers = {"Content-type": "application/json"})
1530 commands["/help"] = help_command