def post(self, request: Request) -> Response: try: slack_request = SlackActionRequest(request) slack_request.validate() except SlackRequestError as e: return self.respond(status=e.status) # Actions list may be empty when receiving a dialog response. action_list_raw = slack_request.data.get("actions", []) action_list = [] action_option = None for action_data in action_list_raw: # Get the _first_ value in the action list. value = action_data.get("value") if value and not action_option: action_option = value if "name" in action_data: action_list.append(MessageAction(**action_data)) # If a user is just clicking our auto response in the messages tab we just return a 200 if action_option == "sentry_docs_link_clicked": return self.respond() # TODO(mgaeta): Stop short-circuiting here on VALUE alone. if action_option in ["link", "ignore"]: return self.handle_unfurl(slack_request, action_option) if action_option in ["approve_member", "reject_member"]: return self.handle_member_approval(slack_request, action_option) if action_list and action_list[0].name == "enable_notifications": return self.handle_enable_notifications(slack_request) return self._handle_group_actions(slack_request, request, action_list)
def api_error( self, slack_request: SlackActionRequest, group: Group, identity: Identity, error: ApiClient.ApiError, action_type: str, ) -> Response: logger.info( "slack.action.api-error", extra={ **slack_request.get_logging_data(group), "response": str(error.body), "action_type": action_type, }, ) if error.status_code == 403: text = UNLINK_IDENTITY_MESSAGE.format( associate_url=build_unlinking_url( slack_request.integration.id, slack_request.user_id, slack_request.channel_id, slack_request.response_url, ), user_email=identity.user, org_name=group.organization.name, ) else: text = DEFAULT_ERROR_MESSAGE return self.respond_ephemeral(text)
def handle_enable_notifications( self, slack_request: SlackActionRequest) -> Response: try: identity = slack_request.get_identity() except IdentityProvider.DoesNotExist: identity = None if not identity: return self.respond_with_text(NO_IDENTITY_MESSAGE) NotificationSetting.objects.enable_settings_for_user( recipient=identity.user, provider=ExternalProviders.SLACK) return self.respond_with_text(ENABLE_SLACK_SUCCESS_MESSAGE)
def slack_request(self): return SlackActionRequest(self.request)
def handle_member_approval(self, slack_request: SlackActionRequest, action: str) -> Response: try: # get_identity can return nobody identity = slack_request.get_identity() except IdentityProvider.DoesNotExist: identity = None if not identity: return self.respond_with_text(NO_IDENTITY_MESSAGE) member_id = slack_request.callback_data["member_id"] try: member = OrganizationMember.objects.get_member_invite_query( member_id).get() except OrganizationMember.DoesNotExist: # member request is gone, likely someone else rejected it member_email = slack_request.callback_data["member_email"] return self.respond_with_text( f"Member invitation for {member_email} no longer exists.") organization = member.organization if not organization.has_access(identity.user): return self.respond_with_text(NO_ACCESS_MESSAGE) # row should exist because we have access member_of_approver = OrganizationMember.objects.get( user=identity.user, organization=organization) access = from_member(member_of_approver) if not access.has_scope("member:admin"): return self.respond_with_text(NO_PERMISSION_MESSAGE) # validate the org options and check against allowed_roles allowed_roles = member_of_approver.get_allowed_roles_to_invite() try: member.validate_invitation(identity.user, allowed_roles) except UnableToAcceptMemberInvitationException as err: return self.respond_with_text(str(err)) original_status = InviteStatus(member.invite_status) try: if action == "approve_member": member.approve_member_invitation(identity.user, referrer="slack") else: member.reject_member_invitation(identity.user) except Exception as err: # shouldn't error but if it does, respond to the user logger.error( err, extra={ "organization_id": organization.id, "member_id": member.id, }, ) return self.respond_ephemeral(DEFAULT_ERROR_MESSAGE) if action == "approve_member": event_name = "integrations.slack.approve_member_invitation" verb = "approved" else: event_name = "integrations.slack.reject_member_invitation" verb = "rejected" if original_status == InviteStatus.REQUESTED_TO_BE_INVITED: invite_type = "Invite" else: invite_type = "Join" analytics.record( event_name, actor_id=identity.user_id, organization_id=member.organization_id, invitation_type=invite_type.lower(), invited_member_id=member_id, ) manage_url = absolute_uri( reverse("sentry-organization-members", args=[member.organization.slug])) message = SUCCESS_MESSAGE.format( email=member.email, invite_type=invite_type, url=manage_url, verb=verb, ) return self.respond({"text": message})
def _handle_group_actions( self, slack_request: SlackActionRequest, request: Request, action_list: Sequence[MessageAction], ) -> Response: group = get_group(slack_request) if not group: return self.respond(status=403) # Determine the acting user by Slack identity. try: identity = slack_request.get_identity() except IdentityProvider.DoesNotExist: return self.respond(status=403) if not identity: associate_url = build_linking_url( integration=slack_request.integration, slack_id=slack_request.user_id, channel_id=slack_request.channel_id, response_url=slack_request.response_url, ) return self.respond_ephemeral( LINK_IDENTITY_MESSAGE.format(associate_url=associate_url)) # Handle status dialog submission if (slack_request.type == "dialog_submission" and "resolve_type" in slack_request.data["submission"]): # Masquerade a status action action = MessageAction( name="status", value=slack_request.data["submission"]["resolve_type"], ) try: self.on_status(request, identity, group, action) except client.ApiError as error: return self.api_error(slack_request, group, identity, error, "status_dialog") attachment = SlackIssuesMessageBuilder(group, identity=identity, actions=[action]).build() body = self.construct_reply( attachment, is_message=slack_request.callback_data["is_message"]) # use the original response_url to update the link attachment slack_client = SlackClient() try: slack_client.post( slack_request.callback_data["orig_response_url"], data=body, json=True) except ApiError as e: logger.error("slack.action.response-error", extra={"error": str(e)}) return self.respond() # Usually we'll want to respond with the updated attachment including # the list of actions taken. However, when opening a dialog we do not # have anything to update the message with and will use the # response_url later to update it. defer_attachment_update = False # Handle interaction actions for action in action_list: try: if action.name == "status": self.on_status(request, identity, group, action) elif action.name == "assign": self.on_assign(request, identity, group, action) elif action.name == "resolve_dialog": self.open_resolve_dialog(slack_request, group) defer_attachment_update = True except client.ApiError as error: return self.api_error(slack_request, group, identity, error, action.name) if defer_attachment_update: return self.respond() # Reload group as it may have been mutated by the action group = Group.objects.get(id=group.id) attachment = SlackIssuesMessageBuilder(group, identity=identity, actions=action_list).build() body = self.construct_reply(attachment, is_message=_is_message(slack_request.data)) return self.respond(body)
def post(self, request: Request) -> Response: logging_data: Dict[str, str] = {} try: slack_request = SlackActionRequest(request) slack_request.validate() except SlackRequestError as e: return self.respond(status=e.status) data = slack_request.data # Actions list may be empty when receiving a dialog response action_list = data.get("actions", []) action_option = action_list and action_list[0].get("value", "") # if a user is just clicking our auto response in the messages tab we just return a 200 if action_option == "sentry_docs_link_clicked": return self.respond() channel_id = slack_request.channel_id user_id = slack_request.user_id integration = slack_request.integration response_url = data.get("response_url") if action_option in ["link", "ignore"]: analytics.record( "integrations.slack.chart_unfurl_action", organization_id=integration.organizations.all()[0].id, action=action_option, ) payload = {"delete_original": "true"} try: post(response_url, json=payload) except ApiError as e: logger.error("slack.action.response-error", extra={"error": str(e)}) return self.respond(status=403) return self.respond() logging_data["channel_id"] = channel_id logging_data["slack_user_id"] = user_id logging_data["response_url"] = response_url logging_data["integration_id"] = integration.id # Determine the issue group action is being taken on group_id = slack_request.callback_data["issue"] logging_data["group_id"] = group_id try: group = Group.objects.select_related("project__organization").get( project__in=Project.objects.filter( organization__in=integration.organizations.all()), id=group_id, ) except Group.DoesNotExist: logger.info("slack.action.invalid-issue", extra=logging_data) return self.respond(status=403) logging_data["organization_id"] = group.organization.id # Determine the acting user by slack identity try: idp = IdentityProvider.objects.get( type="slack", external_id=slack_request.team_id) except IdentityProvider.DoesNotExist: logger.error("slack.action.invalid-team-id", extra=logging_data) return self.respond(status=403) try: identity = Identity.objects.select_related("user").get( idp=idp, external_id=user_id) except Identity.DoesNotExist: associate_url = build_linking_url(integration, group.organization, user_id, channel_id, response_url) return self.respond({ "response_type": "ephemeral", "replace_original": False, "text": LINK_IDENTITY_MESSAGE.format(associate_url=associate_url), }) # Handle status dialog submission if slack_request.type == "dialog_submission" and "resolve_type" in data[ "submission"]: # Masquerade a status action action = { "name": "status", "value": data["submission"]["resolve_type"] } try: self.on_status(request, identity, group, action, data, integration) except client.ApiError as e: if e.status_code == 403: text = UNLINK_IDENTITY_MESSAGE.format( associate_url=build_unlinking_url( integration.id, user_id, channel_id, response_url), user_email=identity.user, org_name=group.organization.name, ) else: text = DEFAULT_ERROR_MESSAGE return self.api_error(e, "status_dialog", logging_data, text) group = Group.objects.get(id=group.id) attachment = build_group_attachment(group, identity=identity, actions=[action]) body = self.construct_reply( attachment, is_message=slack_request.callback_data["is_message"]) # use the original response_url to update the link attachment slack_client = SlackClient() try: slack_client.post( slack_request.callback_data["orig_response_url"], data=body, json=True) except ApiError as e: logger.error("slack.action.response-error", extra={"error": str(e)}) return self.respond() # Usually we'll want to respond with the updated attachment including # the list of actions taken. However, when opening a dialog we do not # have anything to update the message with and will use the # response_url later to update it. defer_attachment_update = False # Handle interaction actions action_type = None try: for action in action_list: action_type = action["name"] if action_type == "status": self.on_status(request, identity, group, action, data, integration) elif action_type == "assign": self.on_assign(request, identity, group, action) elif action_type == "resolve_dialog": self.open_resolve_dialog(data, group, integration) defer_attachment_update = True except client.ApiError as e: if e.status_code == 403: text = UNLINK_IDENTITY_MESSAGE.format( associate_url=build_unlinking_url(integration.id, user_id, channel_id, response_url), user_email=identity.user, org_name=group.organization.name, ) else: text = DEFAULT_ERROR_MESSAGE return self.api_error(e, action_type, logging_data, text) if defer_attachment_update: return self.respond() # Reload group as it may have been mutated by the action group = Group.objects.get(id=group.id) attachment = build_group_attachment(group, identity=identity, actions=action_list) body = self.construct_reply(attachment, is_message=self.is_message(data)) return self.respond(body)
def handle_member_approval(self, slack_request: SlackActionRequest) -> Response: try: # get_identity can return nobody identity = slack_request.get_identity() except IdentityProvider.DoesNotExist: identity = None if not identity: return self.respond_with_text("Identity not linked for user.") member_id = slack_request.callback_data["member_id"] try: member = OrganizationMember.objects.get_member_invite_query( member_id).get() except OrganizationMember.DoesNotExist: # member request is gone, likely someone else rejected it member_email = slack_request.callback_data["member_email"] return self.respond_with_text( f"Member invitation for {member_email} no longer exists.") organization = member.organization if not organization.has_access(identity.user): return self.respond_with_text( "You don't have access to the organization for the invitation." ) # row should exist because we have access member_of_approver = OrganizationMember.objects.get( user=identity.user, organization=organization) access = from_member(member_of_approver) if not access.has_scope("member:admin"): return self.respond_with_text( "You don't have permission to approve member invitations.") # validate the org options and check against allowed_roles allowed_roles = member_of_approver.get_allowed_roles_to_invite() try: member.validate_invitation(identity.user, allowed_roles) except UnableToAcceptMemberInvitationException as err: return self.respond_with_text(str(err)) original_status = member.invite_status member_email = member.email try: if slack_request.action_option == "approve_member": member.approve_member_invitation(identity.user, referrer="slack") else: member.reject_member_invitation(identity.user) except Exception as err: # shouldn't error but if it does, respond to the user logger.error( err, extra={ "organization_id": organization.id, "member_id": member.id, }, ) return self.respond_ephemeral(DEFAULT_ERROR_MESSAGE) # record analytics and respond with success approve_member = slack_request.action_option == "approve_member" event_name = ("integrations.slack.approve_member_invitation" if approve_member else "integrations.slack.reject_member_invitation") invite_type = ("Invite" if original_status == InviteStatus.REQUESTED_TO_BE_INVITED.value else "Join") analytics.record( event_name, actor_id=identity.user_id, organization_id=member.organization_id, invitation_type=invite_type.lower(), invited_member_id=member_id, ) verb = "approved" if approve_member else "rejected" manage_url = absolute_uri( reverse("sentry-organization-members", args=[member.organization.slug])) body = { "text": f"{invite_type} request for {member_email} has been {verb}. <{manage_url}|See Members and Requests>.", } return self.respond(body)
def post(self, request: Request) -> Response: try: slack_request = SlackActionRequest(request) slack_request.validate() except SlackRequestError as e: return self.respond(status=e.status) action_option = slack_request.action_option if action_option in ["approve_member", "reject_member"]: return self.handle_member_approval(slack_request) # if a user is just clicking our auto response in the messages tab we just return a 200 if action_option == "sentry_docs_link_clicked": return self.respond() # Actions list may be empty when receiving a dialog response data = slack_request.data action_list_raw = data.get("actions", []) action_list = [ MessageAction(**action_data) for action_data in action_list_raw ] organizations = slack_request.integration.organizations.all() if action_option in ["link", "ignore"]: analytics.record( "integrations.slack.chart_unfurl_action", organization_id=organizations[0].id, action=action_option, ) payload = {"delete_original": "true"} try: requests_.post(slack_request.response_url, json=payload) except ApiError as e: logger.error("slack.action.response-error", extra={"error": str(e)}) return self.respond(status=403) return self.respond() # Determine the issue group action is being taken on group_id = slack_request.callback_data["issue"] logging_data = {**slack_request.logging_data, "group_id": group_id} try: group = Group.objects.select_related("project__organization").get( project__in=Project.objects.filter( organization__in=organizations), id=group_id, ) except Group.DoesNotExist: logger.info("slack.action.invalid-issue", extra={**logging_data}) return self.respond(status=403) logging_data["organization_id"] = group.organization.id # Determine the acting user by slack identity try: identity = slack_request.get_identity() except IdentityProvider.DoesNotExist: return self.respond(status=403) if not identity: associate_url = build_linking_url( integration=slack_request.integration, slack_id=slack_request.user_id, channel_id=slack_request.channel_id, response_url=slack_request.response_url, ) return self.respond_ephemeral( LINK_IDENTITY_MESSAGE.format(associate_url=associate_url)) # Handle status dialog submission if slack_request.type == "dialog_submission" and "resolve_type" in data[ "submission"]: # Masquerade a status action action = MessageAction( name="status", value=data["submission"]["resolve_type"], ) try: self.on_status(request, identity, group, action, data, slack_request.integration) except client.ApiError as error: return self.api_error(slack_request, group, identity, error, "status_dialog") group = Group.objects.get(id=group.id) attachment = SlackIssuesMessageBuilder(group, identity=identity, actions=[action]).build() body = self.construct_reply( attachment, is_message=slack_request.callback_data["is_message"]) # use the original response_url to update the link attachment slack_client = SlackClient() try: slack_client.post( slack_request.callback_data["orig_response_url"], data=body, json=True) except ApiError as e: logger.error("slack.action.response-error", extra={"error": str(e)}) return self.respond() # Usually we'll want to respond with the updated attachment including # the list of actions taken. However, when opening a dialog we do not # have anything to update the message with and will use the # response_url later to update it. defer_attachment_update = False # Handle interaction actions for action in action_list: action_type = action.name try: if action_type == "status": self.on_status(request, identity, group, action, data, slack_request.integration) elif action_type == "assign": self.on_assign(request, identity, group, action) elif action_type == "resolve_dialog": self.open_resolve_dialog(data, group, slack_request.integration) defer_attachment_update = True except client.ApiError as error: return self.api_error(slack_request, group, identity, error, action_type) if defer_attachment_update: return self.respond() # Reload group as it may have been mutated by the action group = Group.objects.get(id=group.id) attachment = SlackIssuesMessageBuilder(group, identity=identity, actions=action_list).build() body = self.construct_reply(attachment, is_message=self.is_message(data)) return self.respond(body)