def on_message(self, request: Request, slack_request: SlackDMRequest) -> Response: command = request.data.get("event", {}).get("text", "").lower() if slack_request.is_bot() or not command: return self.respond() headers = { "Authorization": f"Bearer {self._get_access_token(slack_request.integration)}" } payload = { "channel": slack_request.channel_name, **SlackHelpMessageBuilder(command).as_payload(), } client = SlackClient() try: client.post("/chat.postMessage", headers=headers, data=payload, json=True) except ApiError as e: logger.error("slack.event.on-message-error", extra={"error": str(e)}) return self.respond()
def open_resolve_dialog(self, slack_request: SlackActionRequest, group: Group) -> None: # XXX(epurkhiser): In order to update the original message we have to # keep track of the response_url in the callback_id. Definitely hacky, # but seems like there's no other solutions [1]: # # [1]: https://stackoverflow.com/questions/46629852/update-a-bot-message-after-responding-to-a-slack-dialog#comment80795670_46629852 callback_id = json.dumps({ "issue": group.id, "orig_response_url": slack_request.data["response_url"], "is_message": _is_message(slack_request.data), }) dialog = { "callback_id": callback_id, "title": "Resolve Issue", "submit_label": "Resolve", "elements": [RESOLVE_SELECTOR], } payload = { "dialog": json.dumps(dialog), "trigger_id": slack_request.data["trigger_id"], "token": slack_request.integration.metadata["access_token"], } slack_client = SlackClient() try: slack_client.post("/dialog.open", data=payload) except ApiError as e: logger.error("slack.action.response-error", extra={"error": str(e)})
def prompt_link(self, slack_request: SlackDMRequest) -> None: 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, ) if not slack_request.channel_name: return payload = { "token": self._get_access_token(slack_request.integration), "channel": slack_request.channel_name, "user": slack_request.user_id, "text": "Link your Slack identity to Sentry to unfurl Discover charts.", **SlackPromptLinkMessageBuilder(associate_url).as_payload(), } client = SlackClient() try: client.post("/chat.postEphemeral", data=payload) except ApiError as e: logger.error("slack.event.unfurl-error", extra={"error": str(e)}, exc_info=True)
def send_confirmation( integration: Integration, channel_id: str, heading: str, text: str, template: str, request: Request, ) -> HttpResponse: client = SlackClient() token = integration.metadata.get("user_access_token") or integration.metadata["access_token"] payload = { "token": token, "channel": channel_id, "text": text, } headers = {"Authorization": f"Bearer {token}"} try: client.post("/chat.postMessage", headers=headers, data=payload, json=True) except ApiError as e: message = str(e) if message != "Expired url": logger.error("slack.slash-notify.response-error", extra={"error": message}) else: return render_to_response( template, request=request, context={ "heading_text": heading, "body_text": text, "channel_id": channel_id, "team_id": integration.external_id, }, )
def send_incident_alert_notification( action: AlertRuleTriggerAction, incident: Incident, metric_value: int, method: str, ) -> None: # Make sure organization integration is still active: try: integration = Integration.objects.get( id=action.integration_id, organizations=incident.organization, status=ObjectStatus.VISIBLE, ) except Integration.DoesNotExist: # Integration removed, but rule is still active. return channel = action.target_identifier attachment = SlackIncidentsMessageBuilder(incident, action, metric_value, method).build() payload = { "token": integration.metadata["access_token"], "channel": channel, "attachments": json.dumps([attachment]), } client = SlackClient() try: client.post("/chat.postMessage", data=payload, timeout=5) except ApiError as e: logger.info("rule.fail.slack_post", extra={"error": str(e)})
def send_issue_notification_as_slack( notification: Any, user_ids: int, context: Mapping[str, Any], ) -> None: """ Send an "issue notification" to a Slack user which are project level issue alerts """ users = User.objects.filter(id__in=list(user_ids)) external_actors_by_user = get_integrations_by_user_id(context["project"].organization, users) client = SlackClient() for user in users: try: channel, token = get_channel_and_token(external_actors_by_user, user) except AttributeError as e: logger.info( "notification.fail.invalid_slack", extra={ "error": str(e), "notification": "issue_alert", "user": user.id, }, ) continue attachment = [ build_issue_notification_attachment( context["group"], event=context["event"], tags=context["tags"], rules=context["rules"], ) ] payload = { "token": token, "channel": channel, "link_names": 1, "attachments": json.dumps(attachment), } try: client.post("/chat.postMessage", data=payload, timeout=5) except ApiError as e: logger.info( "notification.fail.slack_post", extra={ "error": str(e), "notification": "issue_alert", "user": user.id, "channel_id": channel, }, ) continue metrics.incr( "issue_alert.notifications.sent", instance="slack.issue_alert.notification", skip_internal=False, )
def post_message(payload: Mapping[str, Any], log_error_message: str, log_params: Mapping[str, Any]) -> None: client = SlackClient() try: client.post("/chat.postMessage", data=payload, timeout=5) except ApiError as e: extra = {"error": str(e), **log_params} logger.info(log_error_message, extra=extra)
def send_notification_as_slack( notification: BaseNotification, users: Set[User], shared_context: Mapping[str, Any], extra_context_by_user_id: Mapping[str, Any], ) -> None: """ Send an "activity notification" to a Slack user which are workflow and deploy notification types """ external_actors_by_user = get_integrations_by_user_id( notification.organization, users) client = SlackClient() for user in users: extra_context = (extra_context_by_user_id or {}).get(user.id, {}) try: channel, token = get_channel_and_token(external_actors_by_user, user) except AttributeError as e: logger.info( "notification.fail.invalid_slack", extra={ "error": str(e), "notification": notification, "user": user.id, }, ) continue context = get_context(notification, user, shared_context, extra_context) attachment = [build_notification_attachment(notification, context)] payload = { "token": token, "channel": channel, "link_names": 1, "attachments": json.dumps(attachment), } try: client.post("/chat.postMessage", data=payload, timeout=5) except ApiError as e: logger.info( "notification.fail.slack_post", extra={ "error": str(e), "notification": notification, "user": user.id, "channel_id": channel, }, ) continue metrics.incr( "activity.notifications.sent", instance="slack.activity.notification", skip_internal=False, )
def reply(self, slack_request: SlackDMRequest, message: str) -> Response: headers = {"Authorization": f"Bearer {self._get_access_token(slack_request.integration)}"} payload = {"channel": slack_request.channel_name, "text": message} client = SlackClient() try: client.post("/chat.postMessage", headers=headers, data=payload, json=True) except ApiError as e: logger.error("slack.event.on-message-error", extra={"error": str(e)}) return self.respond()
def send_notification_as_slack( notification: BaseNotification, recipients: Union[Set[User], Set[Team]], shared_context: Mapping[str, Any], extra_context_by_user_id: Mapping[str, Any], ) -> None: """ Send an "activity" or "alert rule" notification to a Slack user or team. """ external_actors_by_recipient = get_integrations_by_recipient_id( notification.organization, recipients) client = SlackClient() for recipient in recipients: extra_context = (extra_context_by_user_id or {}).get(recipient.id, {}) try: channel, token = get_channel_and_token( external_actors_by_recipient, recipient) except AttributeError as e: logger.info( "notification.fail.invalid_slack", extra={ "error": str(e), "notification": notification, "recipient": recipient.id, }, ) continue context = get_context(notification, recipient, shared_context, extra_context) attachment = [build_notification_attachment(notification, context)] payload = { "token": token, "channel": channel, "link_names": 1, "attachments": json.dumps(attachment), } try: client.post("/chat.postMessage", data=payload, timeout=5) except ApiError as e: logger.info( "notification.fail.slack_post", extra={ "error": str(e), "notification": notification, "recipient": recipient.id, "channel_id": channel, }, ) continue key = get_key(notification) metrics.incr( f"{key}.notifications.sent", instance=f"slack.{key}.notification", skip_internal=False, )
def send_notification_as_slack( notification: ActivityNotification, users: Mapping[User, int], shared_context: Mapping[str, Any], ) -> None: external_actors_by_user = get_integrations_by_user_id( notification.organization, users.keys()) client = SlackClient() for user, reason in users.items(): try: channel, token = get_channel_and_token(external_actors_by_user, user) except AttributeError as e: logger.info( "notification.fail.invalid_slack", extra={ "error": str(e), "notification": notification, "user": user.id, }, ) continue context = get_context(notification, user, reason, shared_context) attachment = [build_notification_attachment(notification, context)] payload = { "token": token, "channel": channel, "link_names": 1, "attachments": json.dumps(attachment), } try: client.post("/chat.postMessage", data=payload, timeout=5) except ApiError as e: logger.info( "notification.fail.slack_post", extra={ "error": str(e), "notification": notification, "user": user.id, "channel_id": channel, }, ) continue metrics.incr( "activity.notifications.sent", instance="slack.activity.notification", skip_internal=False, )
def send_message(self, channel_id: str, message: str) -> None: client = SlackClient() token = self.metadata.get("user_access_token") or self.metadata["access_token"] headers = {"Authorization": f"Bearer {token}"} payload = { "token": token, "channel": channel_id, "text": message, } try: client.post("/chat.postMessage", headers=headers, data=payload, json=True) except ApiError as e: message = str(e) if message != "Expired url": logger.error("slack.slash-notify.response-error", extra={"error": message}) return
def prompt_link( self, data: Mapping[str, Any], slack_request: SlackRequest, integration: Integration, ): # This will break if multiple Sentry orgs are added # to a single Slack workspace and a user is a part of one # org and not the other. Since we pick the first org # in the integration organizations set, we might be picking # the org the user is not a part of. organization = integration.organizations.all()[0] associate_url = build_linking_url( integration=integration, organization=organization, slack_id=slack_request.user_id, channel_id=slack_request.channel_id, response_url=slack_request.response_url, ) builder = BlockSlackMessageBuilder() blocks = [ builder.get_markdown_block( "Link your Slack identity to Sentry to unfurl Discover charts." ), builder.get_action_block([("Link", associate_url, "link"), ("Cancel", None, "ignore")]), ] payload = { "token": self._get_access_token(integration), "channel": data["channel"], "user": data["user"], "text": "Link your Slack identity to Sentry to unfurl Discover charts.", "blocks": json.dumps(blocks), } client = SlackClient() try: client.post("/chat.postEphemeral", data=payload) except ApiError as e: logger.error("slack.event.unfurl-error", extra={"error": str(e)}, exc_info=True)
def reply(self, slack_request: SlackRequest, message: str) -> Response: client = SlackClient() access_token = self._get_access_token(slack_request.integration) headers = {"Authorization": f"Bearer {access_token}"} data = slack_request.data.get("event") channel = data["channel"] payload = {"channel": channel, "text": message} try: client.post("/chat.postMessage", headers=headers, data=payload, json=True) except ApiError as e: logger.error("slack.event.on-message-error", extra={"error": str(e)}) return self.respond()
def send_slack_response( integration: Integration, text: str, params: Mapping[str, str], command: str ) -> None: payload = { "replace_original": False, "response_type": "ephemeral", "text": text, } client = SlackClient() if params["response_url"]: path = params["response_url"] headers = {} else: # Command has been invoked in a DM, not as a slash command # we do not have a response URL in this case token = ( integration.metadata.get("user_access_token") or integration.metadata["access_token"] ) headers = {"Authorization": f"Bearer {token}"} payload["token"] = token payload["channel"] = params["slack_id"] path = "/chat.postMessage" try: client.post(path, headers=headers, data=payload, json=True) except ApiError as e: message = str(e) # If the user took their time to link their slack account, we may no # longer be able to respond, and we're not guaranteed able to post into # the channel. Ignore Expired url errors. # # XXX(epurkhiser): Yes the error string has a space in it. if message != "Expired url": log_message = ( "slack.link-notify.response-error" if command == "link" else "slack.unlink-notify.response-error" ) logger.error(log_message, extra={"error": message})
def on_message(self, request: Request, integration: Integration, token: str, data: Mapping[str, Any]) -> Response: channel = data["channel"] if self.is_bot(data): return self.respond() access_token = self._get_access_token(integration) headers = {"Authorization": "Bearer %s" % access_token} payload = { "channel": channel, **SlackEventMessageBuilder(integration).build() } client = SlackClient() try: client.post("/chat.postMessage", headers=headers, data=payload, json=True) except ApiError as e: logger.error("slack.event.on-message-error", extra={"error": str(e)}) return self.respond()
def send_notification_as_slack(notification: ActivityNotification, user: User, context: Mapping[str, Any]) -> None: external_actors = ExternalActor.objects.filter( provider=ExternalProviders.SLACK.value, actor=user.actor, organization=notification.organization, ).select_related("integration") client = SlackClient() for external_actor in external_actors: attachment = [build_notification_attachment(notification, context)] integration = external_actor.integration if integration: token = integration.metadata["access_token"] payload = { "token": token, "channel": external_actor.external_id, "link_names": 1, "attachments": json.dumps(attachment), } try: client.post("/chat.postMessage", data=payload, timeout=5) except ApiError as e: logger.info( "notification.fail.slack_post", extra={ "error": str(e), "notification": notification, "user": user.id, "channel_id": external_actor.external_id, }, ) metrics.incr( "activity.notifications.sent", instance="slack.activity.notification", skip_internal=False, )
def validate_channel_id(name: str, integration_id: Optional[int], input_channel_id: str) -> None: """ In the case that the user is creating an alert via the API and providing the channel ID and name themselves, we want to make sure both values are correct. """ try: integration = Integration.objects.get(id=integration_id) except Integration.DoesNotExist: raise Http404 token = integration.metadata["access_token"] headers = {"Authorization": f"Bearer {token}"} payload = {"channel": input_channel_id} client = SlackClient() try: results = client.get("/conversations.info", headers=headers, params=payload) except ApiError as e: if e.text == "channel_not_found": raise ValidationError("Channel not found. Invalid ID provided.") logger.info("rule.slack.conversation_info_failed", extra={"error": str(e)}) raise IntegrationError("Could not retrieve Slack channel information.") if not isinstance(results, dict): raise IntegrationError("Bad slack channel list response.") stripped_channel_name = strip_channel_name(name) if not stripped_channel_name == results["channel"]["name"]: channel_name = results["channel"]["name"] raise ValidationError( f"Received channel name {channel_name} does not match inputted channel name {stripped_channel_name}." )
def on_message(self, request: Request, integration: Integration, token: str, data: Mapping[str, Any]) -> Response: channel = data["channel"] # if it's a message posted by our bot, we don't want to respond since # that will cause an infinite loop of messages if data.get("bot_id"): return self.respond() access_token = self._get_access_token(integration) headers = {"Authorization": "Bearer %s" % access_token} payload = { "channel": channel, **SlackEventMessageBuilder(integration).build() } client = SlackClient() try: client.post("/chat.postMessage", headers=headers, data=payload, json=True) except ApiError as e: logger.error("slack.event.on-message-error", extra={"error": str(e)}) return self.respond()
def get_users(integration: Integration, organization: Organization) -> Sequence[Mapping[str, Any]]: access_token = (integration.metadata.get("user_access_token") or integration.metadata["access_token"]) headers = {"Authorization": f"Bearer {access_token}"} client = SlackClient() user_list = [] next_cursor = None for i in range(SLACK_GET_USERS_PAGE_LIMIT): try: next_users = client.get( "/users.list", headers=headers, params={ "limit": SLACK_GET_USERS_PAGE_SIZE, "cursor": next_cursor }, ) except ApiError as e: logger.info( "post_install.fail.slack_users.list", extra={ "error": str(e), "organization": organization.slug, "integration_id": integration.id, }, ) break user_list += next_users["members"] next_cursor = next_users["response_metadata"]["next_cursor"] if not next_cursor: break return user_list
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 on_link_shared( self, request: Request, slack_request: SlackRequest, ) -> Optional[Response]: matches: Dict[LinkType, List[UnfurlableUrl]] = defaultdict(list) links_seen = set() integration = slack_request.integration data = slack_request.data.get("event") # An unfurl may have multiple links to unfurl for item in data["links"]: try: url = item["url"] slack_shared_link = parse_link(url) except Exception as e: logger.error("slack.parse-link-error", extra={"error": str(e)}) continue # We would like to track what types of links users are sharing, but # it's a little difficult to do in Sentry because we filter requests # from Slack bots. Instead we just log to Kibana. logger.info("slack.link-shared", extra={"slack_shared_link": slack_shared_link}) link_type, args = match_link(url) # Link can't be unfurled if link_type is None or args is None: continue if (link_type == LinkType.DISCOVER and not slack_request.has_identity and features.has( "organizations:chart-unfurls", slack_request.integration.organizations.all()[0], actor=request.user, )): analytics.record( "integrations.slack.chart_unfurl", organization_id=integration.organizations.all()[0].id, unfurls_count=0, ) self.prompt_link(data, slack_request, integration) return self.respond() # Don't unfurl the same thing multiple times seen_marker = hash(json.dumps((link_type, args), sort_keys=True)) if seen_marker in links_seen: continue links_seen.add(seen_marker) matches[link_type].append(UnfurlableUrl(url=url, args=args)) if not matches: return None # Unfurl each link type results: Dict[str, Any] = {} for link_type, unfurl_data in matches.items(): results.update(link_handlers[link_type].fn(request, integration, unfurl_data, slack_request.user)) if not results: return None access_token = self._get_access_token(integration) payload = { "token": access_token, "channel": data["channel"], "ts": data["message_ts"], "unfurls": json.dumps(results), } client = SlackClient() try: client.post("/chat.unfurl", data=payload) except ApiError as e: logger.error("slack.event.unfurl-error", extra={"error": str(e)}, exc_info=True) return self.respond()
def get_channel_id_with_timeout( integration: "Integration", name: Optional[str], timeout: int, ) -> Tuple[str, Optional[str], bool]: """ Fetches the internal slack id of a channel. :param integration: The slack integration :param name: The name of the channel :param timeout: Our self-imposed time limit. :return: a tuple of three values 1. prefix: string (`"#"` or `"@"`) 2. channel_id: string or `None` 3. timed_out: boolean (whether we hit our self-imposed time limit) """ headers = { "Authorization": f"Bearer {integration.metadata['access_token']}" } payload = { "exclude_archived": False, "exclude_members": True, "types": "public_channel,private_channel", } list_types = LIST_TYPES time_to_quit = time.time() + timeout client = SlackClient() id_data: Optional[Tuple[str, Optional[str], bool]] = None found_duplicate = False prefix = "" for list_type, result_name, prefix in list_types: cursor = "" while True: endpoint = f"/{list_type}.list" try: # Slack limits the response of `<list_type>.list` to 1000 channels items = client.get(endpoint, headers=headers, params=dict(payload, cursor=cursor, limit=1000)) except ApiRateLimitedError as e: logger.info(f"rule.slack.{list_type}_list_rate_limited", extra={"error": str(e)}) raise e except ApiError as e: logger.info(f"rule.slack.{list_type}_list_rate_limited", extra={"error": str(e)}) return prefix, None, False if not isinstance(items, dict): continue for c in items[result_name]: # The "name" field is unique (this is the username for users) # so we return immediately if we find a match. # convert to lower case since all names in Slack are lowercase if name and c["name"].lower() == name.lower(): return prefix, c["id"], False # If we don't get a match on a unique identifier, we look through # the users' display names, and error if there is a repeat. if list_type == "users": profile = c.get("profile") if profile and profile.get("display_name") == name: if id_data: found_duplicate = True else: id_data = (prefix, c["id"], False) cursor = items.get("response_metadata", {}).get("next_cursor", None) if time.time() > time_to_quit: return prefix, None, True if not cursor: break if found_duplicate: raise DuplicateDisplayNameError(name) elif id_data: return id_data return prefix, None, False
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: 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)
def send_notification_as_slack( notification: BaseNotification, recipients: Union[Set[User], Set[Team]], shared_context: Mapping[str, Any], extra_context_by_user_id: Mapping[str, Any], ) -> None: """Send an "activity" or "alert rule" notification to a Slack user or team.""" client = SlackClient() data = get_channel_and_token_by_recipient(notification.organization, recipients) for recipient, tokens_by_channel in data.items(): is_multiple = True if len([token for token in tokens_by_channel ]) > 1 else False if is_multiple: logger.info( "notification.multiple.slack_post", extra={ "notification": notification, "recipient": recipient.id, }, ) extra_context = (extra_context_by_user_id or {}).get(recipient.id, {}) context = get_context(notification, recipient, shared_context, extra_context) attachment = [ build_notification_attachment(notification, context, recipient) ] for channel, token in tokens_by_channel.items(): # unfurl_links and unfurl_media are needed to preserve the intended message format # and prevent the app from replying with help text to the unfurl payload = { "token": token, "channel": channel, "link_names": 1, "unfurl_links": False, "unfurl_media": False, "text": notification.get_notification_title(), "attachments": json.dumps(attachment), } try: client.post("/chat.postMessage", data=payload, timeout=5) except ApiError as e: logger.info( "notification.fail.slack_post", extra={ "error": str(e), "notification": notification, "recipient": recipient.id, "channel_id": channel, "is_multiple": is_multiple, }, ) continue key = get_key(notification) metrics.incr( f"{key}.notifications.sent", instance=f"slack.{key}.notification", skip_internal=False, )
def on_link_shared(self, request: Request, integration: Integration, token: str, data: Mapping[str, Any]) -> Optional[Response]: matches: Dict[LinkType, List[UnfurlableUrl]] = defaultdict(list) links_seen = set() # An unfurl may have multiple links to unfurl for item in data["links"]: try: # We would like to track what types of links users are sharing, # but it's a little difficult to do in sentry since we filter # requests from Slack bots. Instead we just log to Kibana logger.info( "slack.link-shared", extra={"slack_shared_link": parse_link(item["url"])}) except Exception as e: logger.error("slack.parse-link-error", extra={"error": str(e)}) link_type, args = match_link(item["url"]) # Link can't be unfurled if link_type is None or args is None: continue # Don't unfurl the same thing multiple times seen_marker = hash(json.dumps((link_type, args), sort_keys=True)) if seen_marker in links_seen: continue links_seen.add(seen_marker) matches[link_type].append(UnfurlableUrl(url=item["url"], args=args)) if not matches: return None # Unfurl each link type results: Dict[str, Any] = {} for link_type, unfurl_data in matches.items(): results.update(link_handlers[link_type].fn(request, integration, unfurl_data)) if not results: return None access_token = self._get_access_token(integration) payload = { "token": access_token, "channel": data["channel"], "ts": data["message_ts"], "unfurls": json.dumps(results), } client = SlackClient() try: client.post("/chat.unfurl", data=payload) except ApiError as e: logger.error("slack.event.unfurl-error", extra={"error": str(e)}, exc_info=True) return self.respond()