diff --git a/src/dispatch/plugins/dispatch_slack/commands.py b/src/dispatch/plugins/dispatch_slack/commands.py index 436f1d087056..715ab7daa4ec 100644 --- a/src/dispatch/plugins/dispatch_slack/commands.py +++ b/src/dispatch/plugins/dispatch_slack/commands.py @@ -154,7 +154,7 @@ def list_tasks( slack_client, command["channel_id"], command["user_id"], - "Incident List Tasks", + "Incident Task List", blocks=blocks, ) @@ -232,6 +232,6 @@ def list_participants(incident_id: int, command: dict = None, db_session=None): slack_client, command["channel_id"], command["user_id"], - "Incident List Participants", + "Incident Participant List", blocks=blocks, ) diff --git a/src/dispatch/plugins/dispatch_slack/messaging.py b/src/dispatch/plugins/dispatch_slack/messaging.py index 4385b6c169ae..d1f29715c93f 100644 --- a/src/dispatch/plugins/dispatch_slack/messaging.py +++ b/src/dispatch/plugins/dispatch_slack/messaging.py @@ -93,22 +93,66 @@ }, } -INCIDENT_CONVERSATION_NON_INCIDENT_CONVERSATION_COMMAND_ERROR = """ -Looks like you tried to run `{{command}}` in an non-incident conversation. You can only run Dispatch commands in incident conversations.""".replace( +INCIDENT_CONVERSATION_COMMAND_RUN_IN_NONINCIDENT_CONVERSATION = """ +Looks like you tried to run `{{command}}` in an nonincident conversation. +Incident-specifc commands can only be run in incident conversations.""".replace( "\n", " " ).strip() +INCIDENT_CONVERSATION_COMMAND_RUN_IN_CONVERSATION_WHERE_BOT_NOT_PRESENT = """ +Looks like you tried to run `{{command}}` in a conversation where the Dispatch bot is not present. +Add the bot to your conversation or run the command in one of the following conversations: {{conversations}}""".replace( + "\n", " " +).strip() -def render_non_incident_conversation_command_error_message(command: str): - """Renders a non-incident conversation command error ephemeral message.""" + +def create_command_run_in_nonincident_conversation_message(command: str): + """Creates a message for when an incident specific command is run in an nonincident conversation.""" return { "response_type": "ephemeral", - "text": Template(INCIDENT_CONVERSATION_NON_INCIDENT_CONVERSATION_COMMAND_ERROR).render( + "text": Template(INCIDENT_CONVERSATION_COMMAND_RUN_IN_NONINCIDENT_CONVERSATION).render( command=command ), } +def create_command_run_in_conversation_where_bot_not_present_message( + command: str, conversations: [] +): + """Creates a message for when a nonincident specific command is run in a conversation where the Dispatch bot is not present.""" + conversations = (", ").join([f"#{conversation}" for conversation in conversations]) + return { + "response_type": "ephemeral", + "text": Template( + INCIDENT_CONVERSATION_COMMAND_RUN_IN_CONVERSATION_WHERE_BOT_NOT_PRESENT + ).render(command=command, conversations=conversations), + } + + +def create_incident_reported_confirmation_message( + title: str, incident_type: str, incident_priority: str +): + """Creates an incident reported confirmation message.""" + return [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "This is a confirmation that you have reported a security incident with the following information. You'll get invited to a Slack conversation soon.", + }, + }, + {"type": "section", "text": {"type": "mrkdwn", "text": f"*Incident Title*: {title}"}}, + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"*Incident Type*: {incident_type}"}, + }, + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"*Incident Priority*: {incident_priority}"}, + }, + ] + + def get_template(message_type: MessageType): """Fetches the correct template based on message type.""" template_map = { @@ -125,10 +169,7 @@ def get_template(message_type: MessageType): default_notification, INCIDENT_TASK_REMINDER_DESCRIPTION, ), - MessageType.incident_status_reminder: ( - default_notification, - None, - ), + MessageType.incident_status_reminder: (default_notification, None,), MessageType.incident_task_list: (default_notification, INCIDENT_TASK_LIST_DESCRIPTION), } @@ -211,26 +252,3 @@ def slack_preview(message, block=None): print(f"https://api.slack.com/tools/block-kit-builder?blocks={message}") else: print(f"https://api.slack.com/docs/messages/builder?msg={message}") - - -def create_incident_reported_confirmation_msg( - title: str, incident_type: str, incident_priority: str -): - return [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "This is a confirmation that you have reported a security incident with the following information. You'll get invited to a Slack conversation soon.", - }, - }, - {"type": "section", "text": {"type": "mrkdwn", "text": f"*Incident Title*: {title}"}}, - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"*Incident Type*: {incident_type}"}, - }, - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"*Incident Priority*: {incident_priority}"}, - }, - ] diff --git a/src/dispatch/plugins/dispatch_slack/modals.py b/src/dispatch/plugins/dispatch_slack/modals.py index 4cef93583f91..61f274df6f7d 100644 --- a/src/dispatch/plugins/dispatch_slack/modals.py +++ b/src/dispatch/plugins/dispatch_slack/modals.py @@ -21,7 +21,7 @@ from dispatch.plugin import service as plugin_service from dispatch.plugins.dispatch_slack import service as dispatch_slack_service -from .messaging import create_incident_reported_confirmation_msg +from .messaging import create_incident_reported_confirmation_message from .service import get_user_profile_by_email @@ -126,7 +126,7 @@ def report_incident_from_submitted_form(action: dict, db_session: Session = None requested_form_incident_priority = parsed_form_data.get(IncidentSlackViewBlockId.priority) # Send a confirmation to the user - msg_template = create_incident_reported_confirmation_msg( + blocks = create_incident_reported_confirmation_message( title=requested_form_title, incident_type=requested_form_incident_type.get("value"), incident_priority=requested_form_incident_priority.get("value"), @@ -135,11 +135,7 @@ def report_incident_from_submitted_form(action: dict, db_session: Session = None user_id = action["user"]["id"] channel_id = submitted_form.get("private_metadata")["channel_id"] dispatch_slack_service.send_ephemeral_message( - client=slack_client, - conversation_id=channel_id, - user_id=user_id, - text="", - blocks=msg_template, + client=slack_client, conversation_id=channel_id, user_id=user_id, text="", blocks=blocks, ) # Create the incident diff --git a/src/dispatch/plugins/dispatch_slack/service.py b/src/dispatch/plugins/dispatch_slack/service.py index a8dcb0a70341..4059fd0b5813 100644 --- a/src/dispatch/plugins/dispatch_slack/service.py +++ b/src/dispatch/plugins/dispatch_slack/service.py @@ -1,16 +1,8 @@ -""" -.. module: dispatch.plugins.dispatch_slack.service - :platform: Unix - :copyright: (c) 2019 by Netflix Inc., see AUTHORS for more - :license: Apache, see LICENSE for more details. -.. moduleauthor:: Kevin Glisson -""" from datetime import datetime, timezone from tenacity import TryAgain, retry, retry_if_exception_type, stop_after_attempt from typing import Any, Dict, List, Optional import functools import logging -import re import slack import time @@ -29,10 +21,6 @@ def create_slack_client(run_async: bool = False): return slack.WebClient(token=str(SLACK_API_BOT_TOKEN), run_async=run_async) -def contains_numbers(string): - return any(char.isdigit() for char in string) - - def resolve_user(client: Any, user_id: str): """Attempts to resolve a user object regardless if email, id, or prefix.""" if SLACK_USER_ID_OVERRIDE: @@ -218,9 +206,14 @@ def get_user_avatar_url(client: Any, email: str): return get_user_info_by_email(client, email)["profile"]["image_512"] -def get_escaped_user_from_command(command_text: str): - """Gets escaped user sent to Slack command.""" - return re.match(r"<@(?P\w+)\|(?P\w+)>", command_text).group("user_id") +@functools.lru_cache() +async def get_conversations_by_user_id_async(client: Any, user_id: str): + """Gets the list of conversations a user is a member of.""" + result = await make_call_async( + client, "users.conversations", user=user_id, types="public_channel", exclude_archived=True + ) + conversations = [c["name"] for c in result["channels"]] + return conversations # note this will get slower over time, we might exclude archived to make it sane @@ -231,24 +224,6 @@ def get_conversation_by_name(client: Any, name: str): return c -def get_conversation_messages_by_reaction(client: Any, conversation_id: str, reaction: str): - """Fetches messages from a conversation by reaction type.""" - messages = [] - for m in list_conversation_messages(client, conversation_id): - if "reactions" in m and m["reactions"][0]["name"] == reaction and m["text"].strip(): - messages.insert( - 0, - { - "datetime": datetime.fromtimestamp(float(m["ts"])) - .astimezone(timezone("America/Los_Angeles")) - .strftime("%Y-%m-%d %H:%M:%S"), - "message": m["text"], - "user": get_user_info_by_id(client, m["user"])["user"]["real_name"], - }, - ) - return messages - - def set_conversation_topic(client: Any, conversation_id: str, topic: str): """Sets the topic of the specified conversation.""" return make_call(client, "conversations.setTopic", channel=conversation_id, topic=topic) @@ -297,25 +272,6 @@ def add_users_to_conversation(client: Any, conversation_id: str, user_ids: List[ make_call(client, "conversations.invite", users=c, channel=conversation_id) -@paginated("members") -def get_conversation_members( - client: Any, conversation_id: str, include_bots: bool = False, **kwargs -): - response = make_call(client, "conversations.members", channel=conversation_id, **kwargs) - - details = [] - for m in response["members"]: - details.append(make_call(client, "users.info", user=m)["user"]) - - response["members"] = details - return response - - -def get_conversation_details(client: Any, conversation_id): - """Get conversation details.""" - return make_call(client, "conversations.info", channel=conversation_id) - - def send_message( client: Any, conversation_id: str, text: str = None, blocks: Dict = None, persist: bool = False ): diff --git a/src/dispatch/plugins/dispatch_slack/views.py b/src/dispatch/plugins/dispatch_slack/views.py index f5cf871f1ab2..01350c987d90 100644 --- a/src/dispatch/plugins/dispatch_slack/views.py +++ b/src/dispatch/plugins/dispatch_slack/views.py @@ -19,13 +19,14 @@ from dispatch.plugins.dispatch_slack import service as dispatch_slack_service from . import __version__ -from .config import SLACK_SIGNING_SECRET, SLACK_COMMAND_REPORT_INCIDENT_SLUG +from .config import SLACK_SIGNING_SECRET, SLACK_COMMAND_REPORT_INCIDENT_SLUG, SLACK_APP_USER_SLUG from .actions import handle_block_action, handle_dialog_action from .commands import command_functions from .events import event_functions, get_channel_id_from_event, EventEnvelope from .messaging import ( INCIDENT_CONVERSATION_COMMAND_MESSAGE, - render_non_incident_conversation_command_error_message, + create_command_run_in_conversation_where_bot_not_present_message, + create_command_run_in_nonincident_conversation_message, ) from .modals import handle_modal_action @@ -136,7 +137,7 @@ async def handle_command( """Handle all incomming Slack commands.""" raw_request_body = bytes.decode(await request.body()) request_body_form = await request.form() - command = request_body_form._dict + command_details = request_body_form._dict # We verify the timestamp verify_timestamp(x_slack_request_timestamp) @@ -147,25 +148,45 @@ async def handle_command( # We add the user-agent string to the response headers response.headers["X-Slack-Powered-By"] = create_ua_string() - # Fetch conversation by channel id - channel_id = command.get("channel_id") + # We fetch conversation by channel id + channel_id = command_details.get("channel_id") conversation = conversation_service.get_by_channel_id_ignoring_channel_type( db_session=db_session, channel_id=channel_id ) + # We get the name of command that was run + command = command_details.get("command") + incident_id = 0 if conversation: incident_id = conversation.incident_id else: - if command.get("command") != SLACK_COMMAND_REPORT_INCIDENT_SLUG: - return render_non_incident_conversation_command_error_message(command.get("command")) + if command == SLACK_COMMAND_REPORT_INCIDENT_SLUG: + # We create an async Slack client + slack_async_client = dispatch_slack_service.create_slack_client(run_async=True) + + # We get the list of conversations the Slack bot is a member of + conversations = await dispatch_slack_service.get_conversations_by_user_id_async( + slack_async_client, SLACK_APP_USER_SLUG + ) - for f in command_functions(command.get("command")): - background_tasks.add_task(f, incident_id, command=command) + # We get the name of conversation where the command was run + conversation_name = command_details.get("channel_name") - return INCIDENT_CONVERSATION_COMMAND_MESSAGE.get( - command.get("command"), f"Running... Command: {command.get('command')}" - ) + if conversation_name not in conversations: + # We let the user know in which conversations they can run the command + return create_command_run_in_conversation_where_bot_not_present_message( + command, conversations + ) + else: + # We let the user know that incident-specific commands + # can only be run in incident conversations + return create_command_run_in_nonincident_conversation_message(command) + + for f in command_functions(command): + background_tasks.add_task(f, incident_id, command=command_details) + + return INCIDENT_CONVERSATION_COMMAND_MESSAGE.get(command, f"Running... Command: {command}") @router.post("/slack/action")