oncall-engine/engine/apps/slack/views.py
Innokentii Konstantinov cf11e96fe2
Handle invitation button press (#1863)
# What this PR does
Handles case when user press "Go to alert group" button in DM with bot.
2023-05-03 08:19:56 +00:00

566 lines
27 KiB
Python

import hashlib
import hmac
import json
import logging
from typing import Optional
from django.conf import settings
from django.http import HttpResponse
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.api.permissions import RBACPermission
from apps.auth_token.auth import PluginAuthentication
from apps.base.utils import live_settings
from apps.slack.scenarios.alertgroup_appearance import STEPS_ROUTING as ALERTGROUP_APPEARANCE_ROUTING
# Importing routes from scenarios
from apps.slack.scenarios.declare_incident import STEPS_ROUTING as DECLARE_INCIDENT_ROUTING
from apps.slack.scenarios.distribute_alerts import STEPS_ROUTING as DISTRIBUTION_STEPS_ROUTING
from apps.slack.scenarios.invited_to_channel import STEPS_ROUTING as INVITED_TO_CHANNEL_ROUTING
from apps.slack.scenarios.manual_incident import STEPS_ROUTING as MANUAL_INCIDENT_ROUTING
from apps.slack.scenarios.notified_user_not_in_channel import STEPS_ROUTING as NOTIFIED_USER_NOT_IN_CHANNEL_ROUTING
from apps.slack.scenarios.onboarding import STEPS_ROUTING as ONBOARDING_STEPS_ROUTING
from apps.slack.scenarios.paging import STEPS_ROUTING as DIRECT_PAGE_ROUTING
from apps.slack.scenarios.profile_update import STEPS_ROUTING as PROFILE_UPDATE_ROUTING
from apps.slack.scenarios.resolution_note import STEPS_ROUTING as RESOLUTION_NOTE_ROUTING
from apps.slack.scenarios.scenario_step import (
EVENT_SUBTYPE_BOT_MESSAGE,
EVENT_SUBTYPE_FILE_SHARE,
EVENT_SUBTYPE_MESSAGE_CHANGED,
EVENT_SUBTYPE_MESSAGE_DELETED,
EVENT_TYPE_APP_MENTION,
EVENT_TYPE_MESSAGE,
EVENT_TYPE_MESSAGE_CHANNEL,
EVENT_TYPE_SUBTEAM_CREATED,
EVENT_TYPE_SUBTEAM_MEMBERS_CHANGED,
EVENT_TYPE_SUBTEAM_UPDATED,
EVENT_TYPE_USER_CHANGE,
PAYLOAD_TYPE_BLOCK_ACTIONS,
PAYLOAD_TYPE_DIALOG_SUBMISSION,
PAYLOAD_TYPE_EVENT_CALLBACK,
PAYLOAD_TYPE_INTERACTIVE_MESSAGE,
PAYLOAD_TYPE_MESSAGE_ACTION,
PAYLOAD_TYPE_SLASH_COMMAND,
PAYLOAD_TYPE_VIEW_SUBMISSION,
ScenarioStep,
)
from apps.slack.scenarios.schedules import STEPS_ROUTING as SCHEDULES_ROUTING
from apps.slack.scenarios.slack_channel import STEPS_ROUTING as CHANNEL_ROUTING
from apps.slack.scenarios.slack_channel_integration import STEPS_ROUTING as SLACK_CHANNEL_INTEGRATION_ROUTING
from apps.slack.scenarios.slack_usergroup import STEPS_ROUTING as SLACK_USERGROUP_UPDATE_ROUTING
from apps.slack.slack_client import SlackClientWithErrorHandling
from apps.slack.slack_client.exceptions import SlackAPIException, SlackAPITokenException
from apps.slack.tasks import clean_slack_integration_leftovers, unpopulate_slack_user_identities
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
from common.oncall_gateway import delete_slack_connector
from .models import SlackMessage, SlackTeamIdentity, SlackUserIdentity
SCENARIOS_ROUTES = [] # Add all other routes here
SCENARIOS_ROUTES.extend(ONBOARDING_STEPS_ROUTING)
SCENARIOS_ROUTES.extend(DISTRIBUTION_STEPS_ROUTING)
SCENARIOS_ROUTES.extend(INVITED_TO_CHANNEL_ROUTING)
SCENARIOS_ROUTES.extend(SCHEDULES_ROUTING)
SCENARIOS_ROUTES.extend(SLACK_CHANNEL_INTEGRATION_ROUTING)
SCENARIOS_ROUTES.extend(ALERTGROUP_APPEARANCE_ROUTING)
SCENARIOS_ROUTES.extend(RESOLUTION_NOTE_ROUTING)
SCENARIOS_ROUTES.extend(SLACK_USERGROUP_UPDATE_ROUTING)
SCENARIOS_ROUTES.extend(CHANNEL_ROUTING)
SCENARIOS_ROUTES.extend(PROFILE_UPDATE_ROUTING)
SCENARIOS_ROUTES.extend(MANUAL_INCIDENT_ROUTING)
SCENARIOS_ROUTES.extend(DIRECT_PAGE_ROUTING)
SCENARIOS_ROUTES.extend(DECLARE_INCIDENT_ROUTING)
SCENARIOS_ROUTES.extend(NOTIFIED_USER_NOT_IN_CHANNEL_ROUTING)
logger = logging.getLogger(__name__)
SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID = "SELECT_ORGANIZATION_AND_ROUTE"
class StopAnalyticsReporting(APIView):
def get(self, request):
response = HttpResponse(
"Your app installation would not be tracked by analytics from backend, "
"use browser plugin to disable from a frontend side. "
)
response.set_cookie("no_track", True, max_age=10 * 360 * 24 * 60 * 60)
return response
class InstallLinkRedirectView(APIView):
def get(self, request, subscription="free", utm="not_specified"):
return HttpResponse(("Sign up is not allowed"), status=status.HTTP_400_BAD_REQUEST)
class SignupRedirectView(APIView):
def get(self, request, subscription="free", utm="not_specified"):
return HttpResponse(("Sign up is not allowed"), status=status.HTTP_400_BAD_REQUEST)
class OAuthSlackView(APIView):
def get(self, request, format=None, subscription="free", utm="not_specified"):
return HttpResponse(("Sign up is not allowed"), status=status.HTTP_400_BAD_REQUEST)
class SlackEventApiEndpointView(APIView):
@staticmethod
def verify_signature(timestamp, signature, body, secret):
# https://github.com/slackapi/python-slack-events-api/blob/master/slackeventsapi/server.py#L47
if hasattr(hmac, "compare_digest"):
req = str.encode("v0:" + str(timestamp) + ":") + body
request_hash = "v0=" + hmac.new(str.encode(secret), req, hashlib.sha256).hexdigest()
return hmac.compare_digest(request_hash, signature)
def get(self, request, format=None):
return Response("hello")
def post(self, request):
logger.info("Request id: {}".format(request.META.get("HTTP_X_REQUEST_ID")))
body = request.body
try:
slack_signature = request.META["HTTP_X_SLACK_SIGNATURE"]
slack_request_timestamp = request.META["HTTP_X_SLACK_REQUEST_TIMESTAMP"]
except KeyError:
logger.warning("X-Slack-Signature or X-Slack-Request_Timestamp don't exist, This request is not from slack")
return Response(status=403)
if not settings.DEBUG:
if live_settings.SLACK_SIGNING_SECRET is None and settings.SLACK_SIGNING_SECRET_LIVE:
raise Exception("Please specify SLACK_SIGNING_SECRET or use DEBUG.")
if not (
SlackEventApiEndpointView.verify_signature(
slack_request_timestamp, slack_signature, body, live_settings.SLACK_SIGNING_SECRET
)
or SlackEventApiEndpointView.verify_signature(
slack_request_timestamp, slack_signature, body, settings.SLACK_SIGNING_SECRET_LIVE
)
):
return Response(status=403)
# Unifying payload
if "payload" in request.data:
payload = request.data["payload"]
else:
payload = request.data
if isinstance(payload, str):
payload = json.JSONDecoder().decode(payload)
logger.info(
"team_id: %s channel_id: %s user_id: %s command: %s event: %s",
payload.get("team_id"),
payload.get("channel_id"),
payload.get("user_id"),
payload.get("command"),
payload.get("event", {}).get("type"),
)
# Checking if it's repeated Slack request
if "HTTP_X_SLACK_RETRY_NUM" in request.META and int(request.META["HTTP_X_SLACK_RETRY_NUM"]) > 1:
logger.critical(
"Slack retries {} time, request data: {}".format(request.META["HTTP_X_SLACK_RETRY_NUM"], request.data)
)
payload["amixr_slack_retries"] = request.META["HTTP_X_SLACK_RETRY_NUM"]
# Initial url verification
if "type" in payload and payload["type"] == "url_verification":
logger.critical("URL verification from Slack side. That's suspicious.")
return Response(payload["challenge"])
# Linking team
slack_team_identity = self._get_slack_team_identity_from_payload(payload)
if not slack_team_identity:
logger.info("Dropping request because it does not have SlackTeamIdentity.")
return Response()
# Means that slack_team_identity unpopulated
if not slack_team_identity.organizations.exists():
logger.warning(f"OnCall Team for SlackTeamIdentity is not detected, stop it!")
# Open pop-up to inform user why OnCall bot doesn't work if any action was triggered
warning_text = (
"OnCall is not able to process this action because this Slack workspace was "
"disconnected from OnCall. Please log in to the OnCall web interface and install "
"Slack Integration with this workspace again."
)
self._open_warning_window_if_needed(payload, slack_team_identity, warning_text)
return Response(status=200)
# Todo: the case when team has no keys is unexpected, investigation is required
if slack_team_identity.access_token is None and slack_team_identity.bot_access_token is None:
logger.info(f"Team {slack_team_identity.slack_id} has no keys, dropping request.")
return Response()
sc = SlackClientWithErrorHandling(slack_team_identity.bot_access_token)
if slack_team_identity.detected_token_revoked is not None:
# check if token is still invalid
try:
sc.api_call(
"auth.test",
team=slack_team_identity,
)
except SlackAPITokenException:
logger.info(f"Team {slack_team_identity.slack_id} has revoked token, dropping request.")
return Response(status=200)
Step = None
step_was_found = False
slack_user_id = None
user = None
# Linking user identity
slack_user_identity = None
if "event" in payload and payload["event"] is not None:
if ("user" in payload["event"]) and slack_team_identity and (payload["event"]["user"] is not None):
if "id" in payload["event"]["user"]:
slack_user_id = payload["event"]["user"]["id"]
elif type(payload["event"]["user"]) is str:
slack_user_id = payload["event"]["user"]
else:
raise Exception("Failed Linking user identity")
elif (
("bot_id" in payload["event"])
and slack_team_identity
and (
payload["event"]["bot_id"] is not None
and "channel_type" in payload["event"]
and payload["event"]["channel_type"] == EVENT_TYPE_MESSAGE_CHANNEL
)
):
response = sc.api_call("bots.info", bot=payload["event"]["bot_id"])
bot_user_id = response.get("bot", {}).get("user_id", "")
# Don't react on own bot's messages.
if bot_user_id == slack_team_identity.bot_user_id:
return Response(status=200)
elif "user" in payload["event"].get("message", {}):
slack_user_id = payload["event"]["message"]["user"]
# event subtype 'message_deleted'
elif "user" in payload["event"].get("previous_message", {}):
slack_user_id = payload["event"]["previous_message"]["user"]
if "user" in payload:
slack_user_id = payload["user"]["id"]
elif "user_id" in payload:
slack_user_id = payload["user_id"]
if slack_user_id is not None and slack_user_id != slack_team_identity.bot_user_id:
slack_user_identity = SlackUserIdentity.objects.filter(
slack_id=slack_user_id,
slack_team_identity=slack_team_identity,
).first()
organization = self._get_organization_from_payload(payload, slack_team_identity)
logger.info("Organization: " + str(organization))
logger.info("SlackUserIdentity detected: " + str(slack_user_identity))
if not slack_user_identity:
if "type" in payload and payload["type"] == PAYLOAD_TYPE_EVENT_CALLBACK:
if payload["event"]["type"] in [
EVENT_TYPE_SUBTEAM_CREATED,
EVENT_TYPE_SUBTEAM_UPDATED,
EVENT_TYPE_SUBTEAM_MEMBERS_CHANGED,
]:
logger.info("Slack event without user slack_id.")
elif payload["event"]["type"] == EVENT_TYPE_USER_CHANGE:
logger.info("Event user_change. Dropping request because it does not have SlackUserIdentity.")
return Response()
else:
logger.info("Dropping request because it does not have SlackUserIdentity.")
self._open_warning_for_unconnected_user(sc, payload)
return Response()
elif organization:
user = slack_user_identity.get_user(organization)
if not user:
# Means that user slack_user_identity is not in any organization, connected to this Slack workspace
warning_text = "Permission denied. Please connect your Slack account to OnCall."
# Open pop-up to inform user why OnCall bot doesn't work if any action was triggered
self._open_warning_window_if_needed(payload, slack_team_identity, warning_text)
return Response(status=200)
elif not slack_user_identity.users.exists():
# Means that slack_user_identity doesn't have any connected user
# Open pop-up to inform user why OnCall bot doesn't work if any action was triggered
self._open_warning_for_unconnected_user(sc, payload)
return Response(status=200)
# Capture cases when we expect stateful message from user
if not step_was_found and "type" in payload and payload["type"] == PAYLOAD_TYPE_EVENT_CALLBACK:
# Message event is from channel
if (
payload["event"]["type"] == EVENT_TYPE_MESSAGE
and payload["event"]["channel_type"] == EVENT_TYPE_MESSAGE_CHANNEL
and (
"subtype" not in payload["event"]
or payload["event"]["subtype"] == EVENT_SUBTYPE_BOT_MESSAGE
or payload["event"]["subtype"] == EVENT_SUBTYPE_MESSAGE_CHANGED
or payload["event"]["subtype"] == EVENT_SUBTYPE_FILE_SHARE
or payload["event"]["subtype"] == EVENT_SUBTYPE_MESSAGE_DELETED
)
):
for route in SCENARIOS_ROUTES:
if (
"message_channel_type" in route
and payload["event"]["channel_type"] == route["message_channel_type"]
):
Step = route["step"]
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
step.process_scenario(slack_user_identity, slack_team_identity, payload)
step_was_found = True
# We don't do anything on app mention, but we doesn't want to unsubscribe from this event yet.
if payload["event"]["type"] == EVENT_TYPE_APP_MENTION:
logger.info(f"Received event of type {EVENT_TYPE_APP_MENTION} from slack. Skipping.")
return Response(status=200)
# Routing to Steps based on routing rules
if not step_was_found:
for route in SCENARIOS_ROUTES:
# Slash commands have to "type"
if "command" in payload and route["payload_type"] == PAYLOAD_TYPE_SLASH_COMMAND:
if payload["command"] in route["command_name"]:
Step = route["step"]
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
step.process_scenario(slack_user_identity, slack_team_identity, payload)
step_was_found = True
if "type" in payload and payload["type"] == route["payload_type"]:
if payload["type"] == PAYLOAD_TYPE_EVENT_CALLBACK:
if payload["event"]["type"] == route["event_type"]:
# event_name is used for stateful
if "event_name" not in route:
Step = route["step"]
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
step.process_scenario(slack_user_identity, slack_team_identity, payload)
step_was_found = True
if payload["type"] == PAYLOAD_TYPE_INTERACTIVE_MESSAGE:
for action in payload["actions"]:
if action["type"] == route["action_type"]:
# Action name may also contain action arguments.
# So only beginning is used for routing.
if action["name"].startswith(route["action_name"]):
Step = route["step"]
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
result = step.process_scenario(slack_user_identity, slack_team_identity, payload)
if result is not None:
return result
step_was_found = True
if payload["type"] == PAYLOAD_TYPE_BLOCK_ACTIONS:
for action in payload["actions"]:
if action["type"] == route["block_action_type"]:
if action["action_id"].startswith(route["block_action_id"]):
Step = route["step"]
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
step.process_scenario(slack_user_identity, slack_team_identity, payload)
step_was_found = True
if payload["type"] == PAYLOAD_TYPE_DIALOG_SUBMISSION:
if payload["callback_id"] == route["dialog_callback_id"]:
Step = route["step"]
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
result = step.process_scenario(slack_user_identity, slack_team_identity, payload)
if result is not None:
return result
step_was_found = True
if payload["type"] == PAYLOAD_TYPE_VIEW_SUBMISSION:
if payload["view"]["callback_id"].startswith(route["view_callback_id"]):
Step = route["step"]
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
result = step.process_scenario(slack_user_identity, slack_team_identity, payload)
if result is not None:
return result
step_was_found = True
if payload["type"] == PAYLOAD_TYPE_MESSAGE_ACTION:
if payload["callback_id"] in route["message_action_callback_id"]:
Step = route["step"]
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
step.process_scenario(slack_user_identity, slack_team_identity, payload)
step_was_found = True
if not step_was_found:
raise Exception("Step is undefined" + str(payload))
return Response(status=200)
def _get_slack_team_identity_from_payload(self, payload) -> Optional[SlackTeamIdentity]:
slack_team_identity = None
if "team" in payload:
slack_team_id = payload["team"]["id"]
elif "team_id" in payload:
slack_team_id = payload["team_id"]
else:
return slack_team_identity
try:
slack_team_identity = SlackTeamIdentity.objects.get(slack_id=slack_team_id)
except SlackTeamIdentity.DoesNotExist as e:
logger.warning("Team identity not detected, that's dangerous!" + str(e))
return slack_team_identity
def _get_organization_from_payload(self, payload, slack_team_identity):
message_ts = None
channel_id = None
organization = None
# view submission or actions in view
if "view" in payload:
organization_id = None
private_metadata = payload["view"].get("private_metadata")
# steps with private_metadata in which we know organization before open view
if private_metadata and "organization_id" in private_metadata:
organization_id = json.loads(private_metadata).get("organization_id")
# steps with organization selection in view (e.g. slash commands)
elif SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID in payload["view"].get("state", {}).get("values", {}):
payload_values = payload["view"]["state"]["values"]
selected_value = payload_values[SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID][
SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID
]["selected_option"]["value"]
organization_id = int(selected_value.split("-")[0])
if organization_id:
organization = slack_team_identity.organizations.get(pk=organization_id)
return organization
# buttons and actions
elif payload.get("type") in [
PAYLOAD_TYPE_BLOCK_ACTIONS,
PAYLOAD_TYPE_INTERACTIVE_MESSAGE,
PAYLOAD_TYPE_MESSAGE_ACTION,
]:
# for cases when we put organization_id into action value (e.g. public suggestion)
if (
payload.get("actions")
and payload["actions"][0].get("value", {})
and "organization_id" in payload["actions"][0]["value"]
):
organization_id = int(json.loads(payload["actions"][0]["value"])["organization_id"])
organization = slack_team_identity.organizations.get(pk=organization_id)
return organization
channel_id = payload["channel"]["id"]
if "message" in payload:
message_ts = payload["message"].get("thread_ts") or payload["message"]["ts"]
# for interactive message
elif "message_ts" in payload:
message_ts = payload["message_ts"]
else:
return
# events
elif payload.get("type") == PAYLOAD_TYPE_EVENT_CALLBACK:
if "channel" in payload["event"]: # events without channel: user_change, events with subteam, etc.
channel_id = payload["event"]["channel"]
if "message" in payload["event"]:
message_ts = payload["event"]["message"].get("thread_ts") or payload["event"]["message"]["ts"]
elif "thread_ts" in payload["event"]:
message_ts = payload["event"]["thread_ts"]
else:
return
if not (message_ts and channel_id):
return
try:
slack_message = SlackMessage.objects.get(
slack_id=message_ts,
_slack_team_identity=slack_team_identity,
channel_id=channel_id,
)
except SlackMessage.DoesNotExist:
pass
else:
alert_group = slack_message.get_alert_group()
if alert_group:
organization = alert_group.channel.organization
return organization
return organization
def _open_warning_window_if_needed(self, payload, slack_team_identity, warning_text) -> None:
if payload.get("trigger_id") is not None:
step = ScenarioStep(slack_team_identity)
try:
step.open_warning_window(payload, warning_text)
except SlackAPIException as e:
logger.info(
f"Failed to open pop-up for unpopulated SlackTeamIdentity {slack_team_identity.pk}\n" f"Error: {e}"
)
def _open_warning_for_unconnected_user(self, slack_client, payload):
if payload.get("trigger_id") is None:
return
text = (
"The information in this workspace is read-only. To interact with OnCall alert groups you need to connect a personal account.\n"
"Please go to *Grafana* -> *OnCall* -> *Users*, "
"choose *your profile* and click the *connect* button.\n"
":rocket: :rocket: :rocket:"
)
view = {
"blocks": (
{"type": "section", "block_id": "section-identifier", "text": {"type": "mrkdwn", "text": text}},
),
"type": "modal",
"callback_id": "modal-identifier",
"title": {
"type": "plain_text",
"text": "One more step!",
},
}
slack_client.api_call(
"views.open",
trigger_id=payload["trigger_id"],
view=view,
)
class ResetSlackView(APIView):
permission_classes = (IsAuthenticated, RBACPermission)
authentication_classes = [PluginAuthentication]
rbac_permissions = {
"post": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS],
}
def post(self, request):
if settings.SLACK_INTEGRATION_MAINTENANCE_ENABLED:
response = Response(
"Grafana OnCall is temporary unable to connect your slack account or install OnCall to your slack workspace",
status=400,
)
else:
organization = request.auth.organization
slack_team_identity = organization.slack_team_identity
if slack_team_identity is not None:
clean_slack_integration_leftovers.apply_async((organization.pk,))
if settings.FEATURE_MULTIREGION_ENABLED:
delete_slack_connector(str(organization.uuid))
write_chatops_insight_log(
author=request.user,
event_name=ChatOpsEvent.WORKSPACE_DISCONNECTED,
chatops_type=ChatOpsTypePlug.SLACK.value,
)
unpopulate_slack_user_identities(organization.pk, True)
response = Response(status=200)
else:
response = Response(status=400)
return response