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("State", "state",
220 "State of this puzzle (partial progress, next steps)",
221 initial_value=puzzle.get("state", None),
224 "Puzzle status", "Solved", "solved",
225 checked=(puzzle.get('status', 'unsolved') == 'solved')),
226 input_block("Solution", "solution",
227 "Solution(s) (comma-separated if multiple)",
228 initial_value=solution_str,
233 result = turb.slack_client.views_open(trigger_id=trigger_id,
237 submission_handlers[result['view']['id']] = edit_puzzle_submission
241 def edit_puzzle_submission(turb, payload, metadata):
242 """Handler for the user submitting the edit puzzle modal
244 This is the modal view presented to the user by the edit_puzzle
250 # First, read all the various data from the request
251 meta = json.loads(metadata)
252 puzzle['hunt_id'] = meta['hunt_id']
253 puzzle['SK'] = meta['SK']
254 puzzle['puzzle_id'] = meta['puzzle_id']
255 puzzle['channel_id'] = meta['channel_id']
256 puzzle['channel_url'] = meta['channel_url']
257 puzzle['sheet_url'] = meta['sheet_url']
259 state = payload['view']['state']['values']
260 user_id = payload['user']['id']
262 puzzle['name'] = state['name']['name']['value']
263 url = state['url']['url']['value']
266 if state['meta']['meta']['selected_options']:
267 puzzle['type'] = 'meta'
269 puzzle['type'] = 'plain'
270 rounds = [option['value'] for option in
271 state['rounds']['rounds']['selected_options']]
273 puzzle['rounds'] = rounds
274 new_rounds = state['new_rounds']['new_rounds']['value']
275 puzzle_state = state['state']['state']['value']
277 puzzle['state'] = puzzle_state
278 if state['solved']['solved']['selected_options']:
279 puzzle['status'] = 'solved'
281 puzzle['status'] = 'unsolved'
282 puzzle['solution'] = []
283 solution = state['solution']['solution']['value']
285 puzzle['solution'] = [
286 sol.strip() for sol in solution.split(',')
289 # Verify that there's a solution if the puzzle is mark solved
290 if puzzle['status'] == 'solved' and not puzzle['solution']:
291 return submission_error("solution",
292 "A solved puzzle requires a solution.")
294 if puzzle['status'] == 'unsolved' and puzzle['solution']:
295 return submission_error("solution",
296 "An unsolved puzzle should have no solution.")
298 # Add any new rounds to the database
300 if 'rounds' not in puzzle:
301 puzzle['rounds'] = []
302 for round in new_rounds.split(','):
303 # Drop any leading/trailing spaces from the round name
304 round = round.strip()
305 # Ignore any empty string
308 puzzle['rounds'].append(round)
311 'hunt_id': puzzle['hunt_id'],
312 'SK': 'round-' + round
316 # Get old puzzle from the database (to determine what's changed)
317 old_puzzle = find_puzzle_for_sort_key(turb,
321 # If we are changing puzzle type (meta -> plain or plain -> meta)
322 # the the sort key has to change, so compute the new one and delete
323 # the old item from the database.
325 # XXX: We should really be using a transaction here to combine the
326 # delete_item and the put_item into a single transaction, but
327 # the boto interface is annoying in that transactions are only on
328 # the "Client" object which has a totally different interface than
329 # the "Table" object I've been using so I haven't figured out how
332 if puzzle['type'] != old_puzzle.get('type', 'plain'):
333 puzzle['SK'] = puzzle_sort_key(puzzle)
334 turb.table.delete_item(Key={
335 'hunt_id': old_puzzle['hunt_id'],
336 'SK': old_puzzle['SK']
339 # Update the puzzle in the database
340 turb.table.put_item(Item=puzzle)
342 # Inform the puzzle channel about the edit
343 edit_message = "Puzzle edited by <@{}>".format(user_id)
344 blocks = ([section_block(text_block(edit_message+":\n"))] +
345 puzzle_blocks(puzzle, include_rounds=True))
347 turb.slack_client, puzzle['channel_id'],
348 edit_message, blocks=blocks)
350 # Also inform the hunt if the puzzle's solved status changed
351 if puzzle['status'] != old_puzzle['status']:
352 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
353 if puzzle['status'] == 'solved':
354 message = "Puzzle <{}|{}> has been solved!".format(
355 puzzle['channel_url'],
358 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
359 puzzle['channel_url'],
361 slack_send_message(turb.slack_client, hunt['channel_id'], message)
363 # We need to set the channel topic if any of puzzle name, url,
364 # state, status, or solution, has changed. Let's just do that
365 # unconditionally here.
366 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
370 def edit_hunt_command(turb, body):
371 """Implementation of the `/hunt edit` command
373 As dispatched from the hunt() function.
376 channel_id = body['channel_id'][0]
377 trigger_id = body['trigger_id'][0]
379 hunt = hunt_for_channel(turb, channel_id)
382 return bot_reply("Sorry, this does not appear to be a hunt channel.")
384 return edit_hunt(turb, hunt, trigger_id)
386 def edit_hunt_button(turb, payload):
387 """Handler for the action of user pressing an edit_hunt button"""
389 hunt_id = payload['actions'][0]['action_id']
390 trigger_id = payload['trigger_id']
392 hunt = find_hunt_for_hunt_id(turb, hunt_id)
395 return bot_reply("Error: Hunt not found.")
397 return edit_hunt(turb, hunt, trigger_id)
399 actions['button']['edit_hunt'] = edit_hunt_button
401 def edit_hunt(turb, hunt, trigger_id):
402 """Common code for implementing an edit hunt dialog
404 This implementation is common whether the edit operation was invoked
405 by a button (edit_hunt_button) or a command (edit_hunt_command).
410 "private_metadata": json.dumps({
411 "hunt_id": hunt["hunt_id"],
413 "is_hunt": hunt["is_hunt"],
414 "channel_id": hunt["channel_id"],
415 "sheet_url": hunt["sheet_url"],
416 "folder_id": hunt["folder_id"],
418 "title": { "type": "plain_text", "text": "Edit Hunt" },
419 "submit": { "type": "plain_text", "text": "Save" },
421 input_block("Hunt name", "name", "Name of the hunt",
422 initial_value=hunt["name"]),
423 input_block("Hunt URL", "url", "External URL of hunt",
424 initial_value=hunt.get("url", None),
426 checkbox_block("Is this hunt active?", "Active", "active",
427 checked=(hunt.get('active', False)))
431 result = turb.slack_client.views_open(trigger_id=trigger_id,
435 submission_handlers[result['view']['id']] = edit_hunt_submission
439 def edit_hunt_submission(turb, payload, metadata):
440 """Handler for the user submitting the edit hunt modal
442 This is the modal view presented by the edit_hunt function above.
447 # First, read all the various data from the request
448 meta = json.loads(metadata)
449 hunt['hunt_id'] = meta['hunt_id']
450 hunt['SK'] = meta['SK']
451 hunt['is_hunt'] = meta['is_hunt']
452 hunt['channel_id'] = meta['channel_id']
453 hunt['sheet_url'] = meta['sheet_url']
454 hunt['folder_id'] = meta['folder_id']
456 state = payload['view']['state']['values']
457 user_id = payload['user']['id']
459 hunt['name'] = state['name']['name']['value']
460 url = state['url']['url']['value']
464 if state['active']['active']['selected_options']:
465 hunt['active'] = True
467 hunt['active'] = False
469 # Update the hunt in the database
470 turb.table.put_item(Item=hunt)
472 # Inform the hunt channel about the edit
473 edit_message = "Hunt edited by <@{}>".format(user_id)
475 section_block(text_block(edit_message)),
476 section_block(text_block("Hunt name: {}".format(hunt['name']))),
479 url = hunt.get('url', None)
482 section_block(text_block("Hunt URL: {}".format(hunt['url'])))
486 turb.slack_client, hunt['channel_id'],
487 edit_message, blocks=blocks)
491 def new_hunt_command(turb, body):
492 """Implementation of the '/hunt new' command
494 As dispatched from the hunt() function.
497 trigger_id = body['trigger_id'][0]
499 return new_hunt(turb, trigger_id)
501 def new_hunt_button(turb, payload):
502 """Handler for the action of user pressing the new_hunt button"""
504 trigger_id = payload['trigger_id']
506 return new_hunt(turb, trigger_id)
508 def new_hunt(turb, trigger_id):
509 """Common code for implementing a new hunt dialog
511 This implementation is common whether the operations was invoked
512 by a button (new_hunt_button) or a command (new_hunt_command).
517 "private_metadata": json.dumps({}),
518 "title": { "type": "plain_text", "text": "New Hunt" },
519 "submit": { "type": "plain_text", "text": "Create" },
521 input_block("Hunt name", "name", "Name of the hunt"),
522 input_block("Hunt ID", "hunt_id",
523 "Used as puzzle channel prefix "
524 + "(no spaces nor punctuation)"),
525 input_block("Hunt URL", "url", "External URL of hunt",
530 result = turb.slack_client.views_open(trigger_id=trigger_id,
533 submission_handlers[result['view']['id']] = new_hunt_submission
537 actions['button']['new_hunt'] = new_hunt_button
539 def new_hunt_submission(turb, payload, metadata):
540 """Handler for the user submitting the new hunt modal
542 This is the modal view presented to the user by the new_hunt
545 state = payload['view']['state']['values']
546 user_id = payload['user']['id']
547 name = state['name']['name']['value']
548 hunt_id = state['hunt_id']['hunt_id']['value']
549 url = state['url']['url']['value']
551 # Validate that the hunt_id contains no invalid characters
552 if not re.match(valid_id_re, hunt_id):
553 return submission_error("hunt_id",
554 "Hunt ID can only contain lowercase letters, "
555 + "numbers, and underscores")
557 # Check to see if the turbot table exists
559 exists = turb.table.table_status in ("CREATING", "UPDATING",
564 # Create the turbot table if necessary.
566 turb.table = turb.db.create_table(
569 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
570 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
572 AttributeDefinitions=[
573 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
574 {'AttributeName': 'SK', 'AttributeType': 'S'},
575 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
576 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
577 {'AttributeName': 'url', 'AttributeType': 'S'},
578 {'AttributeName': 'puzzle_id', 'AttributeType': 'S'}
580 ProvisionedThroughput={
581 'ReadCapacityUnits': 5,
582 'WriteCapacityUnits': 5
584 GlobalSecondaryIndexes=[
586 'IndexName': 'channel_id_index',
588 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
591 'ProjectionType': 'ALL'
593 'ProvisionedThroughput': {
594 'ReadCapacityUnits': 5,
595 'WriteCapacityUnits': 5
599 'IndexName': 'is_hunt_index',
601 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
604 'ProjectionType': 'ALL'
606 'ProvisionedThroughput': {
607 'ReadCapacityUnits': 5,
608 'WriteCapacityUnits': 5
612 LocalSecondaryIndexes = [
614 'IndexName': 'url_index',
616 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
617 {'AttributeName': 'url', 'KeyType': 'RANGE'},
620 'ProjectionType': 'ALL'
624 'IndexName': 'puzzle_id_index',
626 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
627 {'AttributeName': 'puzzle_id', 'KeyType': 'RANGE'},
630 'ProjectionType': 'ALL'
635 return submission_error(
637 "Still bootstrapping turbot table. Try again in a minute, please.")
639 # Create a channel for the hunt
641 response = turb.slack_client.conversations_create(name=hunt_id)
642 except SlackApiError as e:
643 return submission_error("hunt_id",
644 "Error creating Slack channel: {}"
645 .format(e.response['error']))
647 channel_id = response['channel']['id']
649 # Insert the newly-created hunt into the database
650 # (leaving it as non-active for now until the channel-created handler
651 # finishes fixing it up with a sheet and a companion table)
654 "SK": "hunt-{}".format(hunt_id),
656 "channel_id": channel_id,
662 turb.table.put_item(Item=item)
664 # Invite the initiating user to the channel
665 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
669 def view_submission(turb, payload):
670 """Handler for Slack interactive view submission
672 Specifically, those that have a payload type of 'view_submission'"""
674 view_id = payload['view']['id']
675 metadata = payload['view']['private_metadata']
677 if view_id in submission_handlers:
678 return submission_handlers[view_id](turb, payload, metadata)
680 print("Error: Unknown view ID: {}".format(view_id))
685 def rot(turb, body, args):
686 """Implementation of the /rot command
688 The args string should be as follows:
690 [count|*] String to be rotated
692 That is, the first word of the string is an optional number (or
693 the character '*'). If this is a number it indicates an amount to
694 rotate each character in the string. If the count is '*' or is not
695 present, then the string will be rotated through all possible 25
698 The result of the rotation is returned (with Slack formatting) in
699 the body of the response so that Slack will provide it as a reply
700 to the user who submitted the slash command."""
702 channel_name = body['channel_name'][0]
703 response_url = body['response_url'][0]
704 channel_id = body['channel_id'][0]
706 result = turbot.rot.rot(args)
708 if (channel_name == "directmessage"):
709 requests.post(response_url,
710 json = {"text": result},
711 headers = {"Content-type": "application/json"})
713 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
717 commands["/rot"] = rot
719 def get_table_item(turb, table_name, key, value):
720 """Get an item from the database 'table_name' with 'key' as 'value'
722 Returns a tuple of (item, table) if found and (None, None) otherwise."""
724 table = turb.db.Table(table_name)
726 response = table.get_item(Key={key: value})
728 if 'Item' in response:
729 return (response['Item'], table)
733 def db_entry_for_channel(turb, channel_id):
734 """Given a channel ID return the database item for this channel
736 If this channel is a registered hunt or puzzle channel, return the
737 corresponding row from the database for this channel. Otherwise,
740 Note: If you need to specifically ensure that the channel is a
741 puzzle or a hunt, please call puzzle_for_channel or
742 hunt_for_channel respectively.
745 response = turb.table.query(
746 IndexName = "channel_id_index",
747 KeyConditionExpression=Key("channel_id").eq(channel_id)
750 if response['Count'] == 0:
753 return response['Items'][0]
756 def puzzle_for_channel(turb, channel_id):
758 """Given a channel ID return the puzzle from the database for this channel
760 If the given channel_id is a puzzle's channel, this function
761 returns a dict filled with the attributes from the puzzle's entry
764 Otherwise, this function returns None.
767 entry = db_entry_for_channel(turb, channel_id)
769 if entry and entry['SK'].startswith('puzzle-'):
774 def hunt_for_channel(turb, channel_id):
776 """Given a channel ID return the hunt from the database for this channel
778 This works whether the original channel is a primary hunt channel,
779 or if it is one of the channels of a puzzle belonging to the hunt.
781 Returns None if channel does not belong to a hunt, otherwise a
782 dictionary with all fields from the hunt's row in the table,
783 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
786 entry = db_entry_for_channel(turb, channel_id)
788 # We're done if this channel doesn't exist in the database at all
792 # Also done if this channel is a hunt channel
793 if entry['SK'].startswith('hunt-'):
796 # Otherwise, (the channel is in the database, but is not a hunt),
797 # we expect this to be a puzzle channel instead
798 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
800 # python3.9 has a built-in removeprefix but AWS only has python3.8
801 def remove_prefix(text, prefix):
802 if text.startswith(prefix):
803 return text[len(prefix):]
806 def hunt_rounds(turb, hunt_id):
807 """Returns array of strings giving rounds that exist in the given hunt"""
809 response = turb.table.query(
810 KeyConditionExpression=(
811 Key('hunt_id').eq(hunt_id) &
812 Key('SK').begins_with('round-')
816 if response['Count'] == 0:
819 return [remove_prefix(option['SK'], 'round-')
820 for option in response['Items']]
822 def puzzle(turb, body, args):
823 """Implementation of the /puzzle command
825 The args string can be a sub-command:
827 /puzzle new: Bring up a dialog to create a new puzzle
829 /puzzle edit: Edit the puzzle for the current channel
831 Or with no argument at all:
833 /puzzle: Print details of the current puzzle (if in a puzzle channel)
837 return new_puzzle(turb, body)
840 return edit_puzzle_command(turb, body)
843 return bot_reply("Unknown syntax for `/puzzle` command. " +
844 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
845 "and `/puzzle new` to display, edit, or create " +
848 # For no arguments we print the current puzzle as a reply
849 channel_id = body['channel_id'][0]
850 response_url = body['response_url'][0]
852 puzzle = puzzle_for_channel(turb, channel_id)
855 hunt = hunt_for_channel(turb, channel_id)
858 "This is not a puzzle channel, but is a hunt channel. "
859 + "If you want to create a new puzzle for this hunt, use "
863 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
864 + "channel, so the `/puzzle` command cannot work here.")
866 blocks = puzzle_blocks(puzzle, include_rounds=True)
868 # For a meta puzzle, also display the titles and solutions for all
869 # puzzles in the same round.
870 if puzzle.get('type', 'plain') == 'meta':
871 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
873 # Drop this puzzle itself from the report
874 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
876 for round in puzzle.get('rounds', [None]):
877 answers = round_quoted_puzzles_titles_answers(round, puzzles)
879 section_block(text_block(
880 "*Feeder solutions from round {}*".format(
881 round if round else "<none>"
883 section_block(text_block(answers))
886 requests.post(response_url,
887 json = {'blocks': blocks},
888 headers = {'Content-type': 'application/json'}
893 commands["/puzzle"] = puzzle
895 def new(turb, body, args):
896 """Implementation of the `/new` command
898 This can be used to create a new hunt ("/new hunt") or a new
899 puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
900 default behavior (as it is much more common).
902 This operations are identical to the existing "/hunt new" and
903 "/puzzle new". I don't know that that redundancy is actually
904 helpful in the interface. But at least having both allows us to
905 experiment and decide which is more natural and should be kept
910 return new_hunt_command(turb, body)
912 return new_puzzle(turb, body)
914 commands["/new"] = new
916 def new_puzzle(turb, body):
917 """Implementation of the "/puzzle new" command
919 This brings up a dialog box for creating a new puzzle.
922 channel_id = body['channel_id'][0]
923 trigger_id = body['trigger_id'][0]
925 hunt = hunt_for_channel(turb, channel_id)
928 return bot_reply("Sorry, this channel doesn't appear to "
929 + "be a hunt or puzzle channel")
931 # We used puzzle (if available) to select the initial round(s)
932 puzzle = puzzle_for_channel(turb, channel_id)
933 initial_rounds = None
935 initial_rounds=puzzle.get("rounds", None)
937 round_options = hunt_rounds(turb, hunt['hunt_id'])
939 if len(round_options):
940 round_options_block = [
941 multi_select_block("Round(s)", "rounds",
942 "Existing round(s) this puzzle belongs to",
944 initial_options=initial_rounds)
947 round_options_block = []
951 "private_metadata": json.dumps({
952 "hunt_id": hunt['hunt_id'],
954 "title": {"type": "plain_text", "text": "New Puzzle"},
955 "submit": { "type": "plain_text", "text": "Create" },
957 section_block(text_block("*For {}*".format(hunt['name']))),
958 input_block("Puzzle name", "name", "Name of the puzzle"),
959 input_block("Puzzle URL", "url", "External URL of puzzle",
961 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
962 * round_options_block,
963 input_block("New round(s)", "new_rounds",
964 "New round(s) this puzzle belongs to " +
970 result = turb.slack_client.views_open(trigger_id=trigger_id,
974 submission_handlers[result['view']['id']] = new_puzzle_submission
978 def new_puzzle_submission(turb, payload, metadata):
979 """Handler for the user submitting the new puzzle modal
981 This is the modal view presented to the user by the new_puzzle
985 # First, read all the various data from the request
986 meta = json.loads(metadata)
987 hunt_id = meta['hunt_id']
989 state = payload['view']['state']['values']
991 # And start loading data into a puzzle dict
993 puzzle['hunt_id'] = hunt_id
994 puzzle['name'] = state['name']['name']['value']
995 url = state['url']['url']['value']
998 if state['meta']['meta']['selected_options']:
999 puzzle['type'] = 'meta'
1001 puzzle['type'] = 'plain'
1002 if 'rounds' in state:
1003 rounds = [option['value'] for option in
1004 state['rounds']['rounds']['selected_options']]
1007 new_rounds = state['new_rounds']['new_rounds']['value']
1009 # Create a Slack-channel-safe puzzle_id
1010 puzzle['puzzle_id'] = puzzle_id_from_name(puzzle['name'])
1012 # Before doing anything, reject this puzzle if a puzzle already
1013 # exists with the same puzzle_id or url
1014 existing = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle['puzzle_id'])
1016 return submission_error(
1018 "Error: This name collides with an existing puzzle.")
1021 existing = find_puzzle_for_url(turb, hunt_id, url)
1023 return submission_error(
1025 "Error: A puzzle with this URL already exists.")
1027 # Add any new rounds to the database
1029 for round in new_rounds.split(','):
1030 # Drop any leading/trailing spaces from the round name
1031 round = round.strip()
1032 # Ignore any empty string
1035 rounds.append(round)
1036 turb.table.put_item(
1039 'SK': 'round-' + round
1044 puzzle['rounds'] = rounds
1046 puzzle['solution'] = []
1047 puzzle['status'] = 'unsolved'
1049 # Create a channel for the puzzle
1050 channel_name = puzzle_channel_name(puzzle)
1053 response = turb.slack_client.conversations_create(
1055 except SlackApiError as e:
1056 return submission_error(
1058 "Error creating Slack channel {}: {}"
1059 .format(channel_name, e.response['error']))
1061 puzzle['channel_id'] = response['channel']['id']
1063 # Finally, compute the appropriate sort key
1064 puzzle["SK"] = puzzle_sort_key(puzzle)
1066 # Insert the newly-created puzzle into the database
1067 turb.table.put_item(Item=puzzle)
1071 def state(turb, body, args):
1072 """Implementation of the /state command
1074 The args string should be a brief sentence describing where things
1075 stand or what's needed."""
1077 channel_id = body['channel_id'][0]
1079 old_puzzle = puzzle_for_channel(turb, channel_id)
1083 "Sorry, the /state command only works in a puzzle channel")
1085 # Make a deep copy of the puzzle object
1086 puzzle = puzzle_copy(old_puzzle)
1088 # Update the puzzle in the database
1089 puzzle['state'] = args
1090 turb.table.put_item(Item=puzzle)
1092 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1096 commands["/state"] = state
1098 def tag(turb, body, args):
1099 """Implementation of the `/tag` command.
1101 Arg is either a tag to add (optionally prefixed with '+'), or if
1102 prefixed with '-' is a tag to remove.
1106 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1107 + "or `/tag -TAG_TO_REMOVE`.")
1109 channel_id = body['channel_id'][0]
1111 old_puzzle = puzzle_for_channel(turb, channel_id)
1115 "Sorry, the /tag command only works in a puzzle channel")
1126 # Force tag to all uppercase
1129 # Reject a tag that is not alphabetic or underscore A-Z_
1130 if not re.match(r'^[A-Z0-9_]*$', tag):
1131 return bot_reply("Sorry, tags can only contain letters, numbers, "
1132 + "and the underscore character.")
1134 if action == 'remove':
1135 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1136 return bot_reply("Nothing to do. This puzzle is not tagged "
1137 + "with the tag: {}".format(tag))
1139 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1140 return bot_reply("Nothing to do. This puzzle is already tagged "
1141 + "with the tag: {}".format(tag))
1143 # OK. Error checking is done. Let's get to work
1145 # Make a deep copy of the puzzle object
1146 puzzle = puzzle_copy(old_puzzle)
1148 if action == 'remove':
1149 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1151 if 'tags' not in puzzle:
1152 puzzle['tags'] = [tag]
1154 puzzle['tags'].append(tag)
1156 turb.table.put_item(Item=puzzle)
1158 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1162 commands["/tag"] = tag
1164 def solved(turb, body, args):
1165 """Implementation of the /solved command
1167 The args string should be a confirmed solution."""
1169 channel_id = body['channel_id'][0]
1170 user_id = body['user_id'][0]
1172 old_puzzle = puzzle_for_channel(turb, channel_id)
1175 return bot_reply("Sorry, this is not a puzzle channel.")
1179 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1181 # Make a deep copy of the puzzle object
1182 puzzle = puzzle_copy(old_puzzle)
1184 # Set the status and solution fields in the database
1185 puzzle['status'] = 'solved'
1186 puzzle['solution'].append(args)
1187 if 'state' in puzzle:
1189 turb.table.put_item(Item=puzzle)
1191 # Report the solution to the puzzle's channel
1193 turb.slack_client, channel_id,
1194 "Puzzle mark solved by <@{}>: `{}`".format(user_id, args))
1196 # Also report the solution to the hunt channel
1197 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1199 turb.slack_client, hunt['channel_id'],
1200 "Puzzle <{}|{}> has been solved!".format(
1201 puzzle['channel_url'],
1205 # And update the puzzle's description
1206 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1210 commands["/solved"] = solved
1212 def hunt(turb, body, args):
1213 """Implementation of the /hunt command
1215 The (optional) args string can be used to filter which puzzles to
1216 display. The first word can be one of 'all', 'unsolved', or
1217 'solved' and can be used to display only puzzles with the given
1218 status. If this first word is missing, this command will display
1219 only unsolved puzzles by default.
1221 Any remaining text in the args string will be interpreted as
1222 search terms. These will be split into separate terms on space
1223 characters, (though quotation marks can be used to include a space
1224 character in a term). All terms must match on a puzzle in order
1225 for that puzzle to be included. But a puzzle will be considered to
1226 match if any of the puzzle title, round title, puzzle URL, puzzle
1227 state, puzzle type, tags, or puzzle solution match. Matching will
1228 be performed without regard to case sensitivity and the search
1229 terms can include regular expression syntax.
1233 channel_id = body['channel_id'][0]
1234 response_url = body['response_url'][0]
1236 # First, farm off "/hunt new" and "/hunt edit" a separate commands
1238 return new_hunt_command(turb, body)
1241 return edit_hunt_command(turb, body)
1245 # The first word can be a puzzle status and all remaining word
1246 # (if any) are search terms. _But_, if the first word is not a
1247 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1248 # words are search terms and we default status to 'unsolved'.
1249 split_args = args.split(' ', 1)
1250 status = split_args[0]
1251 if (len(split_args) > 1):
1252 terms = split_args[1]
1253 if status not in ('unsolved', 'solved', 'all'):
1259 # Separate search terms on spaces (but allow for quotation marks
1260 # to capture spaces in a search term)
1262 terms = shlex.split(terms)
1264 hunt = hunt_for_channel(turb, channel_id)
1267 return bot_reply("Sorry, this channel doesn't appear to "
1268 + "be a hunt or puzzle channel")
1270 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1272 for block in blocks:
1273 if len(block) > 100:
1275 requests.post(response_url,
1276 json = { 'blocks': block },
1277 headers = {'Content-type': 'application/json'}
1282 commands["/hunt"] = hunt
1284 def round(turb, body, args):
1285 """Implementation of the /round command
1287 Displays puzzles in the same round(s) as the puzzle for the
1290 The (optional) args string can be used to filter which puzzles to
1291 display. The first word can be one of 'all', 'unsolved', or
1292 'solved' and can be used to display only puzzles with the given
1293 status. If this first word is missing, this command will display
1294 all puzzles in the round by default.
1296 Any remaining text in the args string will be interpreted as
1297 search terms. These will be split into separate terms on space
1298 characters, (though quotation marks can be used to include a space
1299 character in a term). All terms must match on a puzzle in order
1300 for that puzzle to be included. But a puzzle will be considered to
1301 match if any of the puzzle title, round title, puzzle URL, puzzle
1302 state, or puzzle solution match. Matching will be performed
1303 without regard to case sensitivity and the search terms can
1304 include regular expression syntax.
1307 channel_id = body['channel_id'][0]
1308 response_url = body['response_url'][0]
1310 puzzle = puzzle_for_channel(turb, channel_id)
1311 hunt = hunt_for_channel(turb, channel_id)
1316 "This is not a puzzle channel, but is a hunt channel. "
1317 + "Use /hunt if you want to see all rounds for this hunt.")
1320 "Sorry, this channel doesn't appear to be a puzzle channel "
1321 + "so the `/round` command cannot work here.")
1325 # The first word can be a puzzle status and all remaining word
1326 # (if any) are search terms. _But_, if the first word is not a
1327 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1328 # words are search terms and we default status to 'unsolved'.
1329 split_args = args.split(' ', 1)
1330 status = split_args[0]
1331 if (len(split_args) > 1):
1332 terms = split_args[1]
1333 if status not in ('unsolved', 'solved', 'all'):
1339 # Separate search terms on spaces (but allow for quotation marks
1340 # to capture spaces in a search term)
1342 terms = shlex.split(terms)
1344 blocks = hunt_blocks(turb, hunt,
1345 puzzle_status=status, search_terms=terms,
1346 limit_to_rounds=puzzle.get('rounds', [])
1349 for block in blocks:
1350 if len(block) > 100:
1352 requests.post(response_url,
1353 json = { 'blocks': block },
1354 headers = {'Content-type': 'application/json'}
1359 commands["/round"] = round
1361 def help_command(turb, body, args):
1362 """Implementation of the /help command
1364 Displays help on how to use Turbot.
1367 channel_id = body['channel_id'][0]
1368 response_url = body['response_url'][0]
1369 user_id = body['user_id'][0]
1371 # Process "/help me" first. It calls out to have_you_tried rather
1372 # than going through our help system.
1374 # Also, it reports in the current channel, (where all other help
1375 # output is reported privately to the invoking user).
1377 to_try = "In response to <@{}> asking `/help me`:\n\n{}\n".format(
1378 user_id, have_you_tried())
1380 # We'll try first to reply directly to the channel (for the benefit
1381 # of anyone else in the same channel that might be stuck too.
1383 # But if this doesn't work, (direct message or private channel),
1384 # then we can instead reply with an ephemeral message by using
1387 turb.slack_client.chat_postMessage(
1388 channel=channel_id, text=to_try)
1389 except SlackApiError:
1390 requests.post(response_url,
1391 json = {"text": to_try},
1392 headers = {"Content-type": "application/json"})
1395 help_string = turbot_help(args)
1397 requests.post(response_url,
1398 json = {"text": help_string},
1399 headers = {"Content-type": "application/json"})
1403 commands["/help"] = help_command