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 # Advertize any tag additions to the hunt
376 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
378 new_tags = set(puzzle['tags']) - set(old_puzzle['tags'])
380 message = "Puzzle <{}|{}> has been tagged: {}".format(
381 puzzle['channel_url'],
383 ", ".join(['`{}`'.format(t) for t in new_tags])
385 slack_send_message(turb.slack_client, hunt['channel_id'], message)
387 # Also inform the hunt if the puzzle's solved status changed
388 if puzzle['status'] != old_puzzle['status']:
389 if puzzle['status'] == 'solved':
390 message = "Puzzle <{}|{}> has been solved!".format(
391 puzzle['channel_url'],
394 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
395 puzzle['channel_url'],
397 slack_send_message(turb.slack_client, hunt['channel_id'], message)
399 # We need to set the channel topic if any of puzzle name, url,
400 # state, status, or solution, has changed. Let's just do that
401 # unconditionally here.
402 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
406 def edit_hunt_command(turb, body):
407 """Implementation of the `/hunt edit` command
409 As dispatched from the hunt() function.
412 channel_id = body['channel_id'][0]
413 trigger_id = body['trigger_id'][0]
415 hunt = hunt_for_channel(turb, channel_id)
418 return bot_reply("Sorry, this does not appear to be a hunt channel.")
420 return edit_hunt(turb, hunt, trigger_id)
422 def edit_hunt_button(turb, payload):
423 """Handler for the action of user pressing an edit_hunt button"""
425 hunt_id = payload['actions'][0]['action_id']
426 trigger_id = payload['trigger_id']
428 hunt = find_hunt_for_hunt_id(turb, hunt_id)
431 return bot_reply("Error: Hunt not found.")
433 return edit_hunt(turb, hunt, trigger_id)
435 actions['button']['edit_hunt'] = edit_hunt_button
437 def edit_hunt(turb, hunt, trigger_id):
438 """Common code for implementing an edit hunt dialog
440 This implementation is common whether the edit operation was invoked
441 by a button (edit_hunt_button) or a command (edit_hunt_command).
446 "private_metadata": json.dumps({
447 "hunt_id": hunt["hunt_id"],
449 "is_hunt": hunt["is_hunt"],
450 "channel_id": hunt["channel_id"],
451 "sheet_url": hunt["sheet_url"],
452 "folder_id": hunt["folder_id"],
454 "title": { "type": "plain_text", "text": "Edit Hunt" },
455 "submit": { "type": "plain_text", "text": "Save" },
457 input_block("Hunt name", "name", "Name of the hunt",
458 initial_value=hunt["name"]),
459 input_block("Hunt URL", "url", "External URL of hunt",
460 initial_value=hunt.get("url", None),
462 checkbox_block("Is this hunt active?", "Active", "active",
463 checked=(hunt.get('active', False)))
467 result = turb.slack_client.views_open(trigger_id=trigger_id,
471 submission_handlers[result['view']['id']] = edit_hunt_submission
475 def edit_hunt_submission(turb, payload, metadata):
476 """Handler for the user submitting the edit hunt modal
478 This is the modal view presented by the edit_hunt function above.
483 # First, read all the various data from the request
484 meta = json.loads(metadata)
485 hunt['hunt_id'] = meta['hunt_id']
486 hunt['SK'] = meta['SK']
487 hunt['is_hunt'] = meta['is_hunt']
488 hunt['channel_id'] = meta['channel_id']
489 hunt['sheet_url'] = meta['sheet_url']
490 hunt['folder_id'] = meta['folder_id']
492 state = payload['view']['state']['values']
493 user_id = payload['user']['id']
495 hunt['name'] = state['name']['name']['value']
496 url = state['url']['url']['value']
500 if state['active']['active']['selected_options']:
501 hunt['active'] = True
503 hunt['active'] = False
505 # Update the hunt in the database
506 turb.table.put_item(Item=hunt)
508 # Inform the hunt channel about the edit
509 edit_message = "Hunt edited by <@{}>".format(user_id)
511 section_block(text_block(edit_message)),
512 section_block(text_block("Hunt name: {}".format(hunt['name']))),
515 url = hunt.get('url', None)
518 section_block(text_block("Hunt URL: {}".format(hunt['url'])))
522 turb.slack_client, hunt['channel_id'],
523 edit_message, blocks=blocks)
527 def new_hunt_command(turb, body):
528 """Implementation of the '/hunt new' command
530 As dispatched from the hunt() function.
533 trigger_id = body['trigger_id'][0]
535 return new_hunt(turb, trigger_id)
537 def new_hunt_button(turb, payload):
538 """Handler for the action of user pressing the new_hunt button"""
540 trigger_id = payload['trigger_id']
542 return new_hunt(turb, trigger_id)
544 def new_hunt(turb, trigger_id):
545 """Common code for implementing a new hunt dialog
547 This implementation is common whether the operations was invoked
548 by a button (new_hunt_button) or a command (new_hunt_command).
553 "private_metadata": json.dumps({}),
554 "title": { "type": "plain_text", "text": "New Hunt" },
555 "submit": { "type": "plain_text", "text": "Create" },
557 input_block("Hunt name", "name", "Name of the hunt"),
558 input_block("Hunt ID", "hunt_id",
559 "Used as puzzle channel prefix "
560 + "(no spaces nor punctuation)"),
561 input_block("Hunt URL", "url", "External URL of hunt",
566 result = turb.slack_client.views_open(trigger_id=trigger_id,
569 submission_handlers[result['view']['id']] = new_hunt_submission
573 actions['button']['new_hunt'] = new_hunt_button
575 def new_hunt_submission(turb, payload, metadata):
576 """Handler for the user submitting the new hunt modal
578 This is the modal view presented to the user by the new_hunt
581 state = payload['view']['state']['values']
582 user_id = payload['user']['id']
583 name = state['name']['name']['value']
584 hunt_id = state['hunt_id']['hunt_id']['value']
585 url = state['url']['url']['value']
587 # Validate that the hunt_id contains no invalid characters
588 if not re.match(valid_id_re, hunt_id):
589 return submission_error("hunt_id",
590 "Hunt ID can only contain lowercase letters, "
591 + "numbers, and underscores")
593 # Check to see if the turbot table exists
595 exists = turb.table.table_status in ("CREATING", "UPDATING",
600 # Create the turbot table if necessary.
602 turb.table = turb.db.create_table(
605 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
606 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
608 AttributeDefinitions=[
609 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
610 {'AttributeName': 'SK', 'AttributeType': 'S'},
611 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
612 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
613 {'AttributeName': 'url', 'AttributeType': 'S'},
614 {'AttributeName': 'puzzle_id', 'AttributeType': 'S'}
616 ProvisionedThroughput={
617 'ReadCapacityUnits': 5,
618 'WriteCapacityUnits': 5
620 GlobalSecondaryIndexes=[
622 'IndexName': 'channel_id_index',
624 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
627 'ProjectionType': 'ALL'
629 'ProvisionedThroughput': {
630 'ReadCapacityUnits': 5,
631 'WriteCapacityUnits': 5
635 'IndexName': 'is_hunt_index',
637 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
640 'ProjectionType': 'ALL'
642 'ProvisionedThroughput': {
643 'ReadCapacityUnits': 5,
644 'WriteCapacityUnits': 5
648 LocalSecondaryIndexes = [
650 'IndexName': 'url_index',
652 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
653 {'AttributeName': 'url', 'KeyType': 'RANGE'},
656 'ProjectionType': 'ALL'
660 'IndexName': 'puzzle_id_index',
662 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
663 {'AttributeName': 'puzzle_id', 'KeyType': 'RANGE'},
666 'ProjectionType': 'ALL'
671 return submission_error(
673 "Still bootstrapping turbot table. Try again in a minute, please.")
675 # Create a channel for the hunt
677 response = turb.slack_client.conversations_create(name=hunt_id)
678 except SlackApiError as e:
679 return submission_error("hunt_id",
680 "Error creating Slack channel: {}"
681 .format(e.response['error']))
683 channel_id = response['channel']['id']
685 # Insert the newly-created hunt into the database
686 # (leaving it as non-active for now until the channel-created handler
687 # finishes fixing it up with a sheet and a companion table)
690 "SK": "hunt-{}".format(hunt_id),
692 "channel_id": channel_id,
698 turb.table.put_item(Item=item)
700 # Invite the initiating user to the channel
701 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
705 def view_submission(turb, payload):
706 """Handler for Slack interactive view submission
708 Specifically, those that have a payload type of 'view_submission'"""
710 view_id = payload['view']['id']
711 metadata = payload['view']['private_metadata']
713 if view_id in submission_handlers:
714 return submission_handlers[view_id](turb, payload, metadata)
716 print("Error: Unknown view ID: {}".format(view_id))
721 def rot(turb, body, args):
722 """Implementation of the /rot command
724 The args string should be as follows:
726 [count|*] String to be rotated
728 That is, the first word of the string is an optional number (or
729 the character '*'). If this is a number it indicates an amount to
730 rotate each character in the string. If the count is '*' or is not
731 present, then the string will be rotated through all possible 25
734 The result of the rotation is returned (with Slack formatting) in
735 the body of the response so that Slack will provide it as a reply
736 to the user who submitted the slash command."""
738 channel_name = body['channel_name'][0]
739 response_url = body['response_url'][0]
740 channel_id = body['channel_id'][0]
742 result = turbot.rot.rot(args)
744 if (channel_name == "directmessage"):
745 requests.post(response_url,
746 json = {"text": result},
747 headers = {"Content-type": "application/json"})
749 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
753 commands["/rot"] = rot
755 def get_table_item(turb, table_name, key, value):
756 """Get an item from the database 'table_name' with 'key' as 'value'
758 Returns a tuple of (item, table) if found and (None, None) otherwise."""
760 table = turb.db.Table(table_name)
762 response = table.get_item(Key={key: value})
764 if 'Item' in response:
765 return (response['Item'], table)
769 def db_entry_for_channel(turb, channel_id):
770 """Given a channel ID return the database item for this channel
772 If this channel is a registered hunt or puzzle channel, return the
773 corresponding row from the database for this channel. Otherwise,
776 Note: If you need to specifically ensure that the channel is a
777 puzzle or a hunt, please call puzzle_for_channel or
778 hunt_for_channel respectively.
781 response = turb.table.query(
782 IndexName = "channel_id_index",
783 KeyConditionExpression=Key("channel_id").eq(channel_id)
786 if response['Count'] == 0:
789 return response['Items'][0]
792 def puzzle_for_channel(turb, channel_id):
794 """Given a channel ID return the puzzle from the database for this channel
796 If the given channel_id is a puzzle's channel, this function
797 returns a dict filled with the attributes from the puzzle's entry
800 Otherwise, this function returns None.
803 entry = db_entry_for_channel(turb, channel_id)
805 if entry and entry['SK'].startswith('puzzle-'):
810 def hunt_for_channel(turb, channel_id):
812 """Given a channel ID return the hunt from the database for this channel
814 This works whether the original channel is a primary hunt channel,
815 or if it is one of the channels of a puzzle belonging to the hunt.
817 Returns None if channel does not belong to a hunt, otherwise a
818 dictionary with all fields from the hunt's row in the table,
819 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
822 entry = db_entry_for_channel(turb, channel_id)
824 # We're done if this channel doesn't exist in the database at all
828 # Also done if this channel is a hunt channel
829 if entry['SK'].startswith('hunt-'):
832 # Otherwise, (the channel is in the database, but is not a hunt),
833 # we expect this to be a puzzle channel instead
834 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
836 # python3.9 has a built-in removeprefix but AWS only has python3.8
837 def remove_prefix(text, prefix):
838 if text.startswith(prefix):
839 return text[len(prefix):]
842 def hunt_rounds(turb, hunt_id):
843 """Returns array of strings giving rounds that exist in the given hunt"""
845 response = turb.table.query(
846 KeyConditionExpression=(
847 Key('hunt_id').eq(hunt_id) &
848 Key('SK').begins_with('round-')
852 if response['Count'] == 0:
855 return [remove_prefix(option['SK'], 'round-')
856 for option in response['Items']]
858 def puzzle(turb, body, args):
859 """Implementation of the /puzzle command
861 The args string can be a sub-command:
863 /puzzle new: Bring up a dialog to create a new puzzle
865 /puzzle edit: Edit the puzzle for the current channel
867 Or with no argument at all:
869 /puzzle: Print details of the current puzzle (if in a puzzle channel)
873 return new_puzzle(turb, body)
876 return edit_puzzle_command(turb, body)
879 return bot_reply("Unknown syntax for `/puzzle` command. " +
880 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
881 "and `/puzzle new` to display, edit, or create " +
884 # For no arguments we print the current puzzle as a reply
885 channel_id = body['channel_id'][0]
886 response_url = body['response_url'][0]
888 puzzle = puzzle_for_channel(turb, channel_id)
891 hunt = hunt_for_channel(turb, channel_id)
894 "This is not a puzzle channel, but is a hunt channel. "
895 + "If you want to create a new puzzle for this hunt, use "
899 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
900 + "channel, so the `/puzzle` command cannot work here.")
902 blocks = puzzle_blocks(puzzle, include_rounds=True)
904 # For a meta puzzle, also display the titles and solutions for all
905 # puzzles in the same round.
906 if puzzle.get('type', 'plain') == 'meta':
907 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
909 # Drop this puzzle itself from the report
910 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
912 for round in puzzle.get('rounds', [None]):
913 answers = round_quoted_puzzles_titles_answers(round, puzzles)
915 section_block(text_block(
916 "*Feeder solutions from round {}*".format(
917 round if round else "<none>"
919 section_block(text_block(answers))
922 requests.post(response_url,
923 json = {'blocks': blocks},
924 headers = {'Content-type': 'application/json'}
929 commands["/puzzle"] = puzzle
931 def new(turb, body, args):
932 """Implementation of the `/new` command
934 This can be used to create a new hunt ("/new hunt") or a new
935 puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
936 default behavior (as it is much more common).
938 This operations are identical to the existing "/hunt new" and
939 "/puzzle new". I don't know that that redundancy is actually
940 helpful in the interface. But at least having both allows us to
941 experiment and decide which is more natural and should be kept
946 return new_hunt_command(turb, body)
948 return new_puzzle(turb, body)
950 commands["/new"] = new
952 def new_puzzle(turb, body):
953 """Implementation of the "/puzzle new" command
955 This brings up a dialog box for creating a new puzzle.
958 channel_id = body['channel_id'][0]
959 trigger_id = body['trigger_id'][0]
961 hunt = hunt_for_channel(turb, channel_id)
964 return bot_reply("Sorry, this channel doesn't appear to "
965 + "be a hunt or puzzle channel")
967 # We used puzzle (if available) to select the initial round(s)
968 puzzle = puzzle_for_channel(turb, channel_id)
969 initial_rounds = None
971 initial_rounds=puzzle.get("rounds", None)
973 round_options = hunt_rounds(turb, hunt['hunt_id'])
975 if len(round_options):
976 round_options_block = [
977 multi_select_block("Round(s)", "rounds",
978 "Existing round(s) this puzzle belongs to",
980 initial_options=initial_rounds)
983 round_options_block = []
987 "private_metadata": json.dumps({
988 "hunt_id": hunt['hunt_id'],
990 "title": {"type": "plain_text", "text": "New Puzzle"},
991 "submit": { "type": "plain_text", "text": "Create" },
993 section_block(text_block("*For {}*".format(hunt['name']))),
994 input_block("Puzzle name", "name", "Name of the puzzle"),
995 input_block("Puzzle URL", "url", "External URL of puzzle",
997 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
998 * round_options_block,
999 input_block("New round(s)", "new_rounds",
1000 "New round(s) this puzzle belongs to " +
1001 "(comma separated)",
1003 input_block("Tag(s)", "tags",
1004 "Tags for this puzzle (comma separated)",
1009 result = turb.slack_client.views_open(trigger_id=trigger_id,
1013 submission_handlers[result['view']['id']] = new_puzzle_submission
1017 def new_puzzle_submission(turb, payload, metadata):
1018 """Handler for the user submitting the new puzzle modal
1020 This is the modal view presented to the user by the new_puzzle
1024 # First, read all the various data from the request
1025 meta = json.loads(metadata)
1026 hunt_id = meta['hunt_id']
1028 state = payload['view']['state']['values']
1030 # And start loading data into a puzzle dict
1032 puzzle['hunt_id'] = hunt_id
1033 puzzle['name'] = state['name']['name']['value']
1034 url = state['url']['url']['value']
1037 if state['meta']['meta']['selected_options']:
1038 puzzle['type'] = 'meta'
1040 puzzle['type'] = 'plain'
1041 if 'rounds' in state:
1042 rounds = [option['value'] for option in
1043 state['rounds']['rounds']['selected_options']]
1046 new_rounds = state['new_rounds']['new_rounds']['value']
1047 tags = state['tags']['tags']['value']
1049 # Create a Slack-channel-safe puzzle_id
1050 puzzle['puzzle_id'] = puzzle_id_from_name(puzzle['name'])
1052 # Before doing anything, reject this puzzle if a puzzle already
1053 # exists with the same puzzle_id or url
1054 existing = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle['puzzle_id'])
1056 return submission_error(
1058 "Error: This name collides with an existing puzzle.")
1061 existing = find_puzzle_for_url(turb, hunt_id, url)
1063 return submission_error(
1065 "Error: A puzzle with this URL already exists.")
1067 # Add any new rounds to the database
1069 for round in new_rounds.split(','):
1070 # Drop any leading/trailing spaces from the round name
1071 round = round.strip()
1072 # Ignore any empty string
1075 rounds.append(round)
1076 turb.table.put_item(
1079 'SK': 'round-' + round
1086 for tag in tags.split(','):
1087 # Drop any leading/trailing spaces from the tag
1088 tag = tag.strip().upper()
1089 # Ignore any empty string
1092 # Reject a tag that is not alphabetic or underscore A-Z_
1093 if not re.match(r'^[A-Z0-9_]*$', tag):
1094 return submission_error(
1096 "Error: Tags can only contain letters, numbers, "
1097 + "and the underscore character."
1099 puzzle['tags'].append(tag)
1102 puzzle['rounds'] = rounds
1104 puzzle['solution'] = []
1105 puzzle['status'] = 'unsolved'
1107 # Create a channel for the puzzle
1108 channel_name = puzzle_channel_name(puzzle)
1111 response = turb.slack_client.conversations_create(
1113 except SlackApiError as e:
1114 return submission_error(
1116 "Error creating Slack channel {}: {}"
1117 .format(channel_name, e.response['error']))
1119 puzzle['channel_id'] = response['channel']['id']
1121 # Finally, compute the appropriate sort key
1122 puzzle["SK"] = puzzle_sort_key(puzzle)
1124 # Insert the newly-created puzzle into the database
1125 turb.table.put_item(Item=puzzle)
1129 def state(turb, body, args):
1130 """Implementation of the /state command
1132 The args string should be a brief sentence describing where things
1133 stand or what's needed."""
1135 channel_id = body['channel_id'][0]
1137 old_puzzle = puzzle_for_channel(turb, channel_id)
1141 "Sorry, the /state command only works in a puzzle channel")
1143 # Make a deep copy of the puzzle object
1144 puzzle = puzzle_copy(old_puzzle)
1146 # Update the puzzle in the database
1147 puzzle['state'] = args
1148 turb.table.put_item(Item=puzzle)
1150 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1154 commands["/state"] = state
1156 def tag(turb, body, args):
1157 """Implementation of the `/tag` command.
1159 Arg is either a tag to add (optionally prefixed with '+'), or if
1160 prefixed with '-' is a tag to remove.
1164 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1165 + "or `/tag -TAG_TO_REMOVE`.")
1167 channel_id = body['channel_id'][0]
1169 old_puzzle = puzzle_for_channel(turb, channel_id)
1173 "Sorry, the /tag command only works in a puzzle channel")
1184 # Force tag to all uppercase
1187 # Reject a tag that is not alphabetic or underscore A-Z_
1188 if not re.match(r'^[A-Z0-9_]*$', tag):
1189 return bot_reply("Sorry, tags can only contain letters, numbers, "
1190 + "and the underscore character.")
1192 if action == 'remove':
1193 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1194 return bot_reply("Nothing to do. This puzzle is not tagged "
1195 + "with the tag: {}".format(tag))
1197 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1198 return bot_reply("Nothing to do. This puzzle is already tagged "
1199 + "with the tag: {}".format(tag))
1201 # OK. Error checking is done. Let's get to work
1203 # Make a deep copy of the puzzle object
1204 puzzle = puzzle_copy(old_puzzle)
1206 if action == 'remove':
1207 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1209 if 'tags' not in puzzle:
1210 puzzle['tags'] = [tag]
1212 puzzle['tags'].append(tag)
1214 turb.table.put_item(Item=puzzle)
1216 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1218 # Advertize any tag additions to the hunt
1219 new_tags = set(puzzle['tags']) - set(old_puzzle['tags'])
1221 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1222 message = "Puzzle <{}|{}> has been tagged: {}".format(
1223 puzzle['channel_url'],
1225 ", ".join(['`{}`'.format(t) for t in new_tags])
1227 slack_send_message(turb.slack_client, hunt['channel_id'], message)
1231 commands["/tag"] = tag
1233 def solved(turb, body, args):
1234 """Implementation of the /solved command
1236 The args string should be a confirmed solution."""
1238 channel_id = body['channel_id'][0]
1239 user_id = body['user_id'][0]
1241 old_puzzle = puzzle_for_channel(turb, channel_id)
1244 return bot_reply("Sorry, this is not a puzzle channel.")
1248 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1250 # Make a deep copy of the puzzle object
1251 puzzle = puzzle_copy(old_puzzle)
1253 # Set the status and solution fields in the database
1254 puzzle['status'] = 'solved'
1256 # Don't append a duplicate solution
1257 if args not in puzzle['solution']:
1258 puzzle['solution'].append(args)
1259 if 'state' in puzzle:
1261 turb.table.put_item(Item=puzzle)
1263 # Report the solution to the puzzle's channel
1265 turb.slack_client, channel_id,
1266 "Puzzle marked solved by <@{}>: `{}`".format(user_id, args))
1268 # Also report the solution to the hunt channel
1269 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1271 turb.slack_client, hunt['channel_id'],
1272 "Puzzle <{}|{}> has been solved!".format(
1273 puzzle['channel_url'],
1277 # And update the puzzle's description
1278 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1282 commands["/solved"] = solved
1284 def delete(turb, body, args):
1285 """Implementation of the /delete command
1287 The argument to this command is the ID of a hunt.
1289 The command will report an error if the specified hunt is active.
1291 If the hunt is inactive, this command will archive all channels
1296 return bot_reply("Error, no hunt provided. Usage: `/delete HUNT_ID`")
1299 hunt = find_hunt_for_hunt_id(turb, hunt_id)
1302 return bot_reply("Error, no hunt named \"{}\" exists.".format(hunt_id))
1306 "Error, refusing to delete active hunt \"{}\".".format(hunt_id)
1309 if hunt['hunt_id'] != hunt_id:
1311 "Error, expected hunt ID of \"{}\" but found \"{}\".".format(
1312 hunt_id, hunt['hunt_id']
1316 puzzles = hunt_puzzles_for_hunt_id(turb, hunt_id)
1318 for puzzle in puzzles:
1319 channel_id = puzzle['channel_id']
1320 turb.slack_client.conversations_archive(channel=channel_id)
1322 turb.slack_client.conversations_archive(channel=hunt['channel_id'])
1326 commands["/delete"] = delete
1328 def hunt(turb, body, args):
1329 """Implementation of the /hunt command
1331 The (optional) args string can be used to filter which puzzles to
1332 display. The first word can be one of 'all', 'unsolved', or
1333 'solved' and can be used to display only puzzles with the given
1334 status. If this first word is missing, this command will display
1335 only unsolved puzzles by default.
1337 Any remaining text in the args string will be interpreted as
1338 search terms. These will be split into separate terms on space
1339 characters, (though quotation marks can be used to include a space
1340 character in a term). All terms must match on a puzzle in order
1341 for that puzzle to be included. But a puzzle will be considered to
1342 match if any of the puzzle title, round title, puzzle URL, puzzle
1343 state, puzzle type, tags, or puzzle solution match. Matching will
1344 be performed without regard to case sensitivity and the search
1345 terms can include regular expression syntax.
1349 channel_id = body['channel_id'][0]
1350 response_url = body['response_url'][0]
1352 # First, farm off "/hunt new" and "/hunt edit" a separate commands
1354 return new_hunt_command(turb, body)
1357 return edit_hunt_command(turb, body)
1361 # The first word can be a puzzle status and all remaining word
1362 # (if any) are search terms. _But_, if the first word is not a
1363 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1364 # words are search terms and we default status to 'unsolved'.
1365 split_args = args.split(' ', 1)
1366 status = split_args[0]
1367 if (len(split_args) > 1):
1368 terms = split_args[1]
1369 if status not in ('unsolved', 'solved', 'all'):
1375 # Separate search terms on spaces (but allow for quotation marks
1376 # to capture spaces in a search term)
1378 terms = shlex.split(terms)
1380 hunt = hunt_for_channel(turb, channel_id)
1383 return bot_reply("Sorry, this channel doesn't appear to "
1384 + "be a hunt or puzzle channel")
1386 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1388 for block in blocks:
1389 if len(block) > 100:
1391 requests.post(response_url,
1392 json = { 'blocks': block },
1393 headers = {'Content-type': 'application/json'}
1398 commands["/hunt"] = hunt
1400 def round(turb, body, args):
1401 """Implementation of the /round command
1403 Displays puzzles in the same round(s) as the puzzle for the
1406 The (optional) args string can be used to filter which puzzles to
1407 display. The first word can be one of 'all', 'unsolved', or
1408 'solved' and can be used to display only puzzles with the given
1409 status. If this first word is missing, this command will display
1410 all puzzles in the round by default.
1412 Any remaining text in the args string will be interpreted as
1413 search terms. These will be split into separate terms on space
1414 characters, (though quotation marks can be used to include a space
1415 character in a term). All terms must match on a puzzle in order
1416 for that puzzle to be included. But a puzzle will be considered to
1417 match if any of the puzzle title, round title, puzzle URL, puzzle
1418 state, or puzzle solution match. Matching will be performed
1419 without regard to case sensitivity and the search terms can
1420 include regular expression syntax.
1423 channel_id = body['channel_id'][0]
1424 response_url = body['response_url'][0]
1426 puzzle = puzzle_for_channel(turb, channel_id)
1427 hunt = hunt_for_channel(turb, channel_id)
1432 "This is not a puzzle channel, but is a hunt channel. "
1433 + "Use /hunt if you want to see all rounds for this hunt.")
1436 "Sorry, this channel doesn't appear to be a puzzle channel "
1437 + "so the `/round` command cannot work here.")
1441 # The first word can be a puzzle status and all remaining word
1442 # (if any) are search terms. _But_, if the first word is not a
1443 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1444 # words are search terms and we default status to 'unsolved'.
1445 split_args = args.split(' ', 1)
1446 status = split_args[0]
1447 if (len(split_args) > 1):
1448 terms = split_args[1]
1449 if status not in ('unsolved', 'solved', 'all'):
1455 # Separate search terms on spaces (but allow for quotation marks
1456 # to capture spaces in a search term)
1458 terms = shlex.split(terms)
1460 blocks = hunt_blocks(turb, hunt,
1461 puzzle_status=status, search_terms=terms,
1462 limit_to_rounds=puzzle.get('rounds', [])
1465 for block in blocks:
1466 if len(block) > 100:
1468 requests.post(response_url,
1469 json = { 'blocks': block },
1470 headers = {'Content-type': 'application/json'}
1475 commands["/round"] = round
1477 def help_command(turb, body, args):
1478 """Implementation of the /help command
1480 Displays help on how to use Turbot.
1483 channel_id = body['channel_id'][0]
1484 response_url = body['response_url'][0]
1485 user_id = body['user_id'][0]
1487 # Process "/help me" first. It calls out to have_you_tried rather
1488 # than going through our help system.
1490 # Also, it reports in the current channel, (where all other help
1491 # output is reported privately to the invoking user).
1493 to_try = "In response to <@{}> asking `/help me`:\n\n{}\n".format(
1494 user_id, have_you_tried())
1496 # We'll try first to reply directly to the channel (for the benefit
1497 # of anyone else in the same channel that might be stuck too.
1499 # But if this doesn't work, (direct message or private channel),
1500 # then we can instead reply with an ephemeral message by using
1503 turb.slack_client.chat_postMessage(
1504 channel=channel_id, text=to_try)
1505 except SlackApiError:
1506 requests.post(response_url,
1507 json = {"text": to_try},
1508 headers = {"Content-type": "application/json"})
1511 help_string = turbot_help(args)
1513 requests.post(response_url,
1514 json = {"text": help_string},
1515 headers = {"Content-type": "application/json"})
1519 commands["/help"] = help_command