Files
superset2/superset/reports/notifications/slack.py
T

149 lines
5.5 KiB
Python

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from flask import g
from slack_sdk import WebClient
from slack_sdk.errors import (
BotUserAccessError,
SlackClientConfigurationError,
SlackClientError,
SlackObjectFormationError,
SlackRequestError,
SlackTokenRotationError,
)
from superset import feature_flag_manager
from superset.reports.models import ReportRecipientType
from superset.reports.notifications.base import BaseNotification
from superset.reports.notifications.exceptions import (
NotificationAuthorizationException,
NotificationMalformedException,
NotificationParamException,
NotificationUnprocessableException,
SlackV1NotificationError,
)
from superset.reports.notifications.slack_mixin import SlackMixin
from superset.reports.notifications.slack_transport import (
send_slack_text,
send_to_slack_channels,
)
from superset.utils.decorators import statsd_gauge
from superset.utils.slack import (
get_slack_client,
NO_SLACK_RECIPIENTS_MESSAGE,
should_use_v2_api,
)
logger = logging.getLogger(__name__)
SLACK_V1_FILE_UPLOAD_MESSAGE = (
"Slack v1 file uploads are no longer supported because Slack retired "
"`files.upload`. Enable `ALERT_REPORT_SLACK_V2` and grant the Slack bot "
"both the `channels:read` and `groups:read` scopes so the recipient can "
"be upgraded to Slack v2."
)
# Deprecated: Slack v1 will be removed in the next major release. The Slack
# `files.upload` endpoint was retired in 2025, so file-bearing sends already
# fail before attempting the retired v1 upload; only text-only
# `chat_postMessage` sends still work here. When the Slack bot has the
# `channels:read` and `groups:read` scopes, existing v1 recipients are
# auto-upgraded to SlackV2 on their first eligible send.
class SlackNotification(SlackMixin, BaseNotification): # pylint: disable=too-few-public-methods
"""
Sends a slack notification for a report recipient
"""
type = ReportRecipientType.SLACK
@staticmethod
def _send_text(
client: WebClient,
channels: list[str],
body: str,
retry_deadline: float | None = None,
) -> None:
"""Send a text notification once to each configured channel."""
if not channels:
raise NotificationParamException(NO_SLACK_RECIPIENTS_MESSAGE)
send_to_slack_channels(
channels,
lambda target, retry_deadline: send_slack_text(
client,
target,
body,
retry_deadline=retry_deadline,
),
retry_deadline=retry_deadline,
)
def _send_legacy_text(self) -> None:
if self._content.has_attachments:
raise NotificationParamException(SLACK_V1_FILE_UPLOAD_MESSAGE)
body = self._get_body(content=self._content)
global_logs_context = getattr(g, "logs_context", {}) or {}
try:
client = get_slack_client(for_delivery=True)
channels = self._get_channels()
self._send_text(
client,
channels,
body,
retry_deadline=self._content.slack_retry_deadline,
)
logger.info(
"Report sent to slack",
extra={
"execution_id": global_logs_context.get("execution_id"),
},
)
except (
BotUserAccessError,
SlackRequestError,
SlackClientConfigurationError,
) as ex:
raise NotificationParamException(str(ex)) from ex
except SlackObjectFormationError as ex:
raise NotificationMalformedException(str(ex)) from ex
except SlackTokenRotationError as ex:
raise NotificationAuthorizationException(str(ex)) from ex
except SlackClientError as ex:
# SlackClientError is the base class; keep it last so subclasses
# retain their more specific notification classification.
raise NotificationUnprocessableException(str(ex)) from ex
@statsd_gauge("reports.slack.send")
def send_legacy_text(self) -> None:
"""Send through Slack v1 without repeating the v2 availability probe."""
self._send_legacy_text()
@statsd_gauge(
"reports.slack.send",
ignored_exceptions=(SlackV1NotificationError,),
)
def send(self) -> None:
if should_use_v2_api(raise_on_error=self._content.has_attachments):
raise SlackV1NotificationError
if feature_flag_manager.is_feature_enabled("ALERT_REPORT_SLACK_V2"):
# A text-only probe can fail transiently. Still enter the coordinator
# so a successful legacy fallback records its warning and metric.
raise SlackV1NotificationError
self._send_legacy_text()