Skip to content

Commit

Permalink
Handles commands run in non-incident channels (#575)
Browse files Browse the repository at this point in the history
* Reduces calls to command.get()

* Handles when command is run in a conversation where bot is not present

* Updates messaging and removes unused import
  • Loading branch information
mvilanova authored Sep 21, 2020
1 parent b76117e commit 13aa019
Show file tree
Hide file tree
Showing 5 changed files with 96 additions and 105 deletions.
4 changes: 2 additions & 2 deletions src/dispatch/plugins/dispatch_slack/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def list_tasks(
slack_client,
command["channel_id"],
command["user_id"],
"Incident List Tasks",
"Incident Task List",
blocks=blocks,
)

Expand Down Expand Up @@ -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,
)
82 changes: 50 additions & 32 deletions src/dispatch/plugins/dispatch_slack/messaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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),
}

Expand Down Expand Up @@ -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}"},
},
]
10 changes: 3 additions & 7 deletions src/dispatch/plugins/dispatch_slack/modals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"),
Expand All @@ -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
Expand Down
60 changes: 8 additions & 52 deletions src/dispatch/plugins/dispatch_slack/service.py
Original file line number Diff line number Diff line change
@@ -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 <kglisson@netflix.com>
"""
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

Expand All @@ -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:
Expand Down Expand Up @@ -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<user_id>\w+)\|(?P<user_name>\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
Expand All @@ -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)
Expand Down Expand Up @@ -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
):
Expand Down
45 changes: 33 additions & 12 deletions src/dispatch/plugins/dispatch_slack/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down

0 comments on commit 13aa019

Please sign in to comment.