Files
superset2/superset/utils/slack.py
T

755 lines
25 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 functools
import logging
import warnings
from http.client import RemoteDisconnected
from typing import (
Any,
Callable,
NotRequired,
Optional,
TypedDict,
)
from urllib.error import HTTPError, URLError
from flask import current_app as app
from slack_sdk import WebClient
from slack_sdk.errors import (
SlackApiError,
SlackClientError as SlackSDKClientError,
SlackClientNotConnectedError,
)
from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler
from superset import feature_flag_manager
from superset.constants import CACHE_DISABLED_TIMEOUT
from superset.exceptions import SupersetException
from superset.extensions import cache_manager
from superset.extensions.metastore_cache import SupersetMetastoreCache
from superset.reports.schemas import SlackChannelSchema
from superset.utils.backports import StrEnum
from superset.utils.core import recipients_string_to_list
logger = logging.getLogger(__name__)
_SLACK_V1_DEPRECATION_MESSAGE = (
"Slack v1 (the legacy `Slack` recipient type and `files.upload` API) is "
"deprecated and will be removed in the next major release. Slack retired "
"the `files.upload` endpoint in 2025, so v1 file uploads no longer work; "
"only text-only `chat_postMessage` sends still succeed. Grant your Slack "
"bot the `channels:read` and `groups:read` scopes so existing v1 "
"recipients can be auto-upgraded to SlackV2 on "
"their next send."
)
# functools.cache suppresses repeated calls after the first one completes.
# Concurrent first calls may both emit, which is acceptable for a deprecation
# warning and avoids managing additional process-local synchronization.
@functools.cache
def _emit_v1_flag_off_deprecation() -> None:
warnings.warn(_SLACK_V1_DEPRECATION_MESSAGE, DeprecationWarning, stacklevel=3)
logger.warning(
"ALERT_REPORT_SLACK_V2 is disabled; %s", _SLACK_V1_DEPRECATION_MESSAGE
)
@functools.cache
def _emit_v1_scope_missing_deprecation() -> None:
warnings.warn(_SLACK_V1_DEPRECATION_MESSAGE, DeprecationWarning, stacklevel=3)
class SlackChannelTypes(StrEnum):
PUBLIC = "public_channel"
PRIVATE = "private_channel"
_SLACK_CONVERSATION_TYPES = ",".join(SlackChannelTypes)
class SlackChannel(TypedDict):
"""Normalized Slack channel fields used for report-recipient resolution."""
id: str
name: str
is_private: bool
is_member: NotRequired[bool]
class SlackClientError(Exception):
pass
class SlackV2ProbeError(SupersetException):
"""Transient probe failure classified by ``_send`` as a system error.
``BaseReportState._send`` catches this through ``SupersetException`` and
uses the inherited status code to select ERROR severity.
"""
class SlackV2ProbeClientError(SlackV2ProbeError):
"""Permanent probe failure classified by ``_send`` as a client warning.
``BaseReportState._send`` catches this through ``SupersetException`` and
uses this status code to select WARNING severity.
"""
status = 422
class SlackChannelListingError(SupersetException):
"""Slack channel listing failed due to a transient service condition."""
class SlackChannelListingClientError(SlackChannelListingError):
"""Slack channel listing failed due to permanent token or client setup."""
status = 422
class SlackChannelCacheWriteError(SupersetException):
"""The dedicated Slack channel cache warm-up could not store its result."""
_TRANSIENT_SLACK_API_ERROR_CODES = frozenset(
{
"fatal_error",
"internal_error",
"ratelimited",
"request_timeout",
"rollup_error",
"service_unavailable",
"timeout",
}
)
SLACK_TRANSIENT_TRANSPORT_ERRORS: tuple[type[Exception], ...] = (
SlackClientNotConnectedError,
URLError,
ConnectionResetError,
RemoteDisconnected,
TimeoutError,
)
NO_SLACK_RECIPIENTS_MESSAGE = "No recipients saved in the report"
def parse_slack_recipient_targets(target: str) -> list[str]:
"""Parse Slack targets, removing duplicates while preserving their order."""
return list(dict.fromkeys(recipients_string_to_list(target)))
def get_slack_api_error_data(ex: SlackApiError) -> dict[str, Any]:
"""Return a Slack API error payload across SDK response representations."""
response = getattr(ex, "response", None)
data = getattr(response, "data", None)
if not isinstance(data, dict):
data = response if isinstance(response, dict) else {}
return data
def get_slack_api_error_code(ex: SlackApiError) -> str:
"""Return Slack's application-level error code, if present."""
return str(get_slack_api_error_data(ex).get("error") or "")
def get_slack_api_status_code(ex: SlackApiError) -> int | None:
"""Return the HTTP status carried by a Slack API error, if present."""
return getattr(getattr(ex, "response", None), "status_code", None)
def is_transient_slack_api_error(ex: SlackApiError, error_code: str) -> bool:
"""Return whether Slack reported a retryable API or HTTP condition."""
status_code = get_slack_api_status_code(ex)
return bool(
status_code in {408, 429}
or (status_code is not None and 500 <= status_code < 600)
or error_code in _TRANSIENT_SLACK_API_ERROR_CODES
)
def is_transient_slack_transport_error(ex: Exception) -> bool:
"""Classify raw Slack WebClient transport and external-upload failures."""
if isinstance(ex, HTTPError):
return ex.code in {408, 429} or 500 <= ex.code < 600
return isinstance(ex, SLACK_TRANSIENT_TRANSPORT_ERRORS)
def is_retryable_slack_transport_error(ex: Exception) -> bool:
"""Return whether an application retry cannot duplicate an accepted write."""
if isinstance(ex, HTTPError):
return is_transient_slack_transport_error(ex)
return isinstance(ex, SlackClientNotConnectedError)
def get_slack_client(*, for_delivery: bool = False) -> WebClient:
"""Build a Slack client without nested SDK retries for delivery writes."""
token: str = app.config["SLACK_API_TOKEN"]
if callable(token):
token = token()
client = WebClient(
token=token,
proxy=app.config["SLACK_PROXY"],
timeout=app.config["SLACK_API_TIMEOUT"],
retry_handlers=[] if for_delivery else None,
)
max_retry_count = app.config.get("SLACK_API_RATE_LIMIT_RETRY_COUNT", 2)
if not for_delivery:
rate_limit_handler = RateLimitErrorRetryHandler(
max_retry_count=max_retry_count,
)
client.retry_handlers.append(rate_limit_handler)
logger.debug(
"Slack client configured with %d rate limit retries", max_retry_count
)
else:
logger.debug("Slack delivery client configured with SDK retries disabled")
return client
def get_team_id() -> Optional[str]:
"""
Return the Slack workspace (team) ID to target, or None.
On an Enterprise Grid org, an org-scoped token spans multiple workspaces, so
workspace-scoped methods such as ``conversations.list`` need a ``team_id`` to
indicate which workspace to act on. The value is read from the ``SLACK_TEAM_ID``
config var, which may be a string or a callable (mirroring ``SLACK_API_TOKEN``);
when it is unset (the default for workspace-level tokens) None is returned and
no ``team_id`` is sent, preserving the prior behavior.
"""
team_id = app.config.get("SLACK_TEAM_ID")
if callable(team_id):
team_id = team_id()
return team_id or None
def _get_slack_channels_cache_key(team_id: Optional[str]) -> str:
cache_key = "slack_conversations_list"
return f"{cache_key}_{team_id}" if team_id else cache_key
def _slack_channel_cache_uses_report_session() -> bool:
"""Return whether cache writes commit or roll back the report DB session."""
return isinstance(cache_manager.cache.cache, SupersetMetastoreCache)
def get_channels(
team_id: Optional[str] = None,
*,
force: bool = False,
cache_timeout: int | None = None,
cache: bool = True,
raise_on_cache_write_error: bool = False,
) -> list[SlackChannel]:
"""
Retrieves a list of all conversations accessible by the bot
from the Slack API, and caches results (to avoid rate limits).
The Slack API does not provide search so to apply a search use
get_channels_with_search instead.
:param team_id: workspace (team) ID to target, required for org-scoped tokens
on an Enterprise Grid org. Defaults to the configured ``SLACK_TEAM_ID``
(via get_team_id) when not given. When set it also keys the cache so that
distinct workspaces never share cached channel lists; when unset, the
legacy cache key is used so that upgrading does not invalidate existing
caches.
Cache reads and writes are best-effort; a backend failure does not replace
successfully fetched Slack data.
"""
if team_id is None:
team_id = get_team_id()
channels, _ = _get_channels_safely(
team_id=team_id,
force=force,
cache=cache,
cache_timeout=cache_timeout,
write_cache=True,
raise_on_cache_write_error=raise_on_cache_write_error,
)
return channels
def _get_channels_safely(
*,
team_id: str | None,
force: bool,
cache: bool,
write_cache: bool,
cache_timeout: int | None = None,
raise_on_cache_write_error: bool = False,
) -> tuple[list[SlackChannel], bool]:
"""Fetch channels with best-effort cache access and hit provenance."""
cache_key = _get_slack_channels_cache_key(team_id)
effective_timeout = (
app.config["SLACK_CACHE_TIMEOUT"] if cache_timeout is None else cache_timeout
)
cache_enabled = cache and effective_timeout != CACHE_DISABLED_TIMEOUT
if cache_enabled and not force:
try:
cached_channels = cache_manager.cache.get(cache_key)
except Exception: # pylint: disable=broad-exception-caught
cached_channels = None
logger.warning(
"Could not read cached Slack channels; fetching from Slack",
exc_info=True,
)
if cached_channels is not None:
return cached_channels, True
channels = _get_channels(team_id=team_id)
if cache_enabled and write_cache:
cache_write_result: bool | None = None
try:
cache_write_result = cache_manager.cache.set(
cache_key,
channels,
timeout=effective_timeout,
)
except Exception: # pylint: disable=broad-exception-caught
logger.warning(
"Could not cache Slack channels",
exc_info=True,
)
if raise_on_cache_write_error:
raise
if raise_on_cache_write_error and cache_write_result is False:
raise SlackChannelCacheWriteError(
"Slack channels were fetched but the cache rejected the write"
)
return channels, False
def _get_channels_with_cache_status() -> tuple[list[SlackChannel], bool]:
"""Fetch channels and cache-hit provenance using one cache read."""
return _get_channels_safely(
team_id=get_team_id(),
force=False,
cache=True,
write_cache=not _slack_channel_cache_uses_report_session(),
)
def _get_channels(team_id: Optional[str] = None) -> list[SlackChannel]:
client = get_slack_client()
channel_schema = SlackChannelSchema()
channels: list[SlackChannel] = []
extra_params = {"types": _SLACK_CONVERSATION_TYPES}
if team_id:
extra_params["team_id"] = team_id
cursor = None
page_count = 0
logger.info("Starting Slack channels fetch")
try:
while True:
page_count += 1
response = client.conversations_list(
limit=999, cursor=cursor, exclude_archived=True, **extra_params
)
page_channels = response.data["channels"]
channels.extend(channel_schema.load(channel) for channel in page_channels)
logger.debug(
"Fetched page %d: %d channels (total: %d)",
page_count,
len(page_channels),
len(channels),
)
cursor = response.data.get("response_metadata", {}).get("next_cursor")
if not cursor:
break
logger.info(
"Successfully fetched %d Slack channels in %d pages",
len(channels),
page_count,
)
return channels
except SlackApiError as ex:
logger.error(
"Failed to fetch Slack channels after %d pages: %s",
page_count,
str(ex),
exc_info=True,
)
raise
def _filter_slack_channels(
channels: list[SlackChannel],
*,
search_string: str,
types: Optional[list[SlackChannelTypes]],
exact_match: bool,
) -> list[SlackChannel]:
"""Filter a complete Slack channel listing by type and target."""
if types and len(types) != len(SlackChannelTypes):
conditions: list[Callable[[SlackChannel], bool]] = []
if SlackChannelTypes.PUBLIC in types:
conditions.append(lambda channel: not channel["is_private"])
if SlackChannelTypes.PRIVATE in types:
conditions.append(lambda channel: channel["is_private"])
channels = [
channel for channel in channels if any(cond(channel) for cond in conditions)
]
if not search_string:
return channels
search_array = recipients_string_to_list(search_string)
return [
channel
for channel in channels
if any(
(
search.casefold() == channel["name"].casefold()
or search.casefold() == channel["id"].casefold()
if exact_match
else (
search.casefold() in channel["name"].casefold()
or search.casefold() in channel["id"].casefold()
)
)
for search in search_array
)
]
def _get_channels_with_search(
search_string: str = "",
types: Optional[list[SlackChannelTypes]] = None,
exact_match: bool = False,
force: bool = False,
cache: bool = True,
*,
return_cache_status: bool = False,
) -> tuple[list[SlackChannel], bool]:
"""
The slack api is paginated but does not include search, so we need to fetch
all channels and filter them ourselves
This will search by slack name or id
"""
used_cache = False
cache_timeout = app.config["SLACK_CACHE_TIMEOUT"]
cache_enabled = cache and cache_timeout != CACHE_DISABLED_TIMEOUT
try:
if return_cache_status and cache_enabled and not force:
channels, used_cache = _get_channels_with_cache_status()
else:
channels = get_channels(
force=force,
cache=cache_enabled,
cache_timeout=cache_timeout,
)
except SlackApiError as ex:
error_code = get_slack_api_error_code(ex)
error_class = (
SlackChannelListingError
if is_transient_slack_api_error(ex, error_code)
else SlackChannelListingClientError
)
message = f"Failed to list channels: {ex}"
if get_slack_api_status_code(ex) == 429:
message = (
f"Slack API rate limit exceeded: {ex}. For large workspaces, "
"consider increasing SLACK_API_RATE_LIMIT_RETRY_COUNT"
)
raise error_class(message) from ex
except SLACK_TRANSIENT_TRANSPORT_ERRORS as ex:
error_class = (
SlackChannelListingError
if is_transient_slack_transport_error(ex)
else SlackChannelListingClientError
)
raise error_class(f"Failed to list channels: {ex}") from ex
except (SlackSDKClientError, SlackClientError) as ex:
raise SlackChannelListingClientError(f"Failed to list channels: {ex}") from ex
channels = _filter_slack_channels(
channels,
search_string=search_string,
types=types,
exact_match=exact_match,
)
return channels, used_cache
def get_channels_with_search(
search_string: str = "",
types: Optional[list[SlackChannelTypes]] = None,
exact_match: bool = False,
force: bool = False,
cache: bool = True,
) -> list[SlackChannel]:
"""Fetch and filter Slack channels without exposing cache provenance."""
channels, _ = _get_channels_with_search(
search_string=search_string,
types=types,
exact_match=exact_match,
force=force,
cache=cache,
)
return channels
def get_channels_with_search_and_cache_status(
search_string: str = "",
types: Optional[list[SlackChannelTypes]] = None,
exact_match: bool = False,
) -> tuple[list[SlackChannel], bool]:
"""Fetch filtered Slack channels and report whether the listing was cached."""
return _get_channels_with_search(
search_string=search_string,
types=types,
exact_match=exact_match,
return_cache_status=True,
)
def refresh_cached_slack_channels_with_search(
search_string: str = "",
types: Optional[list[SlackChannelTypes]] = None,
exact_match: bool = False,
) -> list[SlackChannel]:
"""Refresh stale channels with a best-effort atomic cache claim.
External cache backends atomically claim the refresh cooldown before listing
channels. A failed listing or cache write releases that claim. Disabled and
metastore-backed caches use an uncached request without a claim because
metastore writes commit the report transaction.
"""
team_id = get_team_id()
cache_key = _get_slack_channels_cache_key(team_id)
cooldown_key = f"{cache_key}_refresh_cooldown"
cache_timeout = app.config["SLACK_CACHE_TIMEOUT"]
if (
_slack_channel_cache_uses_report_session()
or cache_timeout == CACHE_DISABLED_TIMEOUT
):
return get_channels_with_search(
search_string=search_string,
types=types,
exact_match=exact_match,
force=True,
cache=False,
)
cooldown_timeout = app.config.get(
"SLACK_CHANNEL_REFRESH_COOLDOWN_SECONDS",
300,
)
claim_recorded = False
should_refresh = True
try:
claim_recorded = (
cache_manager.cache.add(
cooldown_key,
True,
timeout=cooldown_timeout,
)
is not False
)
should_refresh = claim_recorded
except Exception: # pylint: disable=broad-exception-caught
logger.warning(
"Could not claim Slack channel refresh cooldown; refreshing from Slack",
exc_info=True,
)
if not should_refresh:
try:
cached_channels = cache_manager.cache.get(cache_key)
except Exception: # pylint: disable=broad-exception-caught
cached_channels = None
logger.warning(
"Could not read Slack channels after another worker claimed refresh",
exc_info=True,
)
channels: list[SlackChannel] = (
cached_channels if isinstance(cached_channels, list) else []
)
return _filter_slack_channels(
channels,
search_string=search_string,
types=types,
exact_match=exact_match,
)
keep_claim = False
try:
refreshed_channels = get_channels_with_search(
force=True,
cache=False,
)
try:
cache_updated = (
cache_manager.cache.set(
cache_key,
refreshed_channels,
timeout=cache_timeout,
)
is not False
)
except Exception: # pylint: disable=broad-exception-caught
cache_updated = False
logger.warning(
"Could not cache refreshed Slack channels",
exc_info=True,
)
keep_claim = cache_updated
return _filter_slack_channels(
refreshed_channels,
search_string=search_string,
types=types,
exact_match=exact_match,
)
finally:
if not keep_claim:
try:
cache_manager.cache.delete(cooldown_key)
except Exception: # pylint: disable=broad-exception-caught
logger.warning(
"Could not release Slack channel refresh cooldown",
exc_info=True,
)
_SCOPE_MISSING_ERROR_CODES = frozenset(
{"missing_scope", "not_allowed_token_type", "no_permission"}
)
def should_use_v2_api(*, raise_on_error: bool = False) -> bool:
if not feature_flag_manager.is_feature_enabled("ALERT_REPORT_SLACK_V2"):
_emit_v1_flag_off_deprecation()
return False
try:
client = get_slack_client()
extra_params = {"types": _SLACK_CONVERSATION_TYPES}
team_id = get_team_id()
if team_id:
extra_params["team_id"] = team_id
client.conversations_list(
limit=1,
exclude_archived=True,
**extra_params,
)
logger.info("Slack API v2 is available")
return True
except SlackApiError as ex:
# Only the scope-missing branch is a v1-deprecation signal; other
# SlackApiError codes (invalid_auth, ratelimited, server errors, etc.)
# are unrelated probe failures and should not be reported as a missing
# scope. Scope errors continue through v1 for compatibility. Other
# failures also fall back for text-only reports, while file-bearing
# reports request an exception so monitoring retains the system/client
# classification.
error_code = get_slack_api_error_code(ex)
if error_code in _SCOPE_MISSING_ERROR_CODES:
# The DeprecationWarning fires once per process, but the actionable
# log line fires every send so operators see it in their report logs.
_emit_v1_scope_missing_deprecation()
logger.warning(
"Slack bot is missing the `channels:read` and `groups:read` "
"scopes; falling back to the deprecated "
"v1 API. %s",
_SLACK_V1_DEPRECATION_MESSAGE,
)
else:
logger.warning(
"Slack v2 probe failed with error %r. Investigate the underlying "
"Slack API error; this is not a missing-scope problem.",
error_code or str(ex),
)
if raise_on_error:
error_class = (
SlackV2ProbeError
if is_transient_slack_api_error(ex, error_code)
else SlackV2ProbeClientError
)
raise error_class(
f"Slack v2 availability probe failed: {error_code or str(ex)}"
) from ex
return False
except SLACK_TRANSIENT_TRANSPORT_ERRORS as ex:
logger.warning(
"Slack v2 probe failed (%s: %s).",
type(ex).__name__,
ex,
)
if raise_on_error:
error_class = (
SlackV2ProbeError
if is_transient_slack_transport_error(ex)
else SlackV2ProbeClientError
)
raise error_class(
f"Slack v2 availability probe failed: {type(ex).__name__}: {ex}"
) from ex
return False
except SlackSDKClientError as ex:
# Permanent SDK request/configuration failures are operator-fixable.
# Text reports retain v1 compatibility, while file-bearing reports ask
# the command to preserve the client-error classification.
logger.warning(
"Slack v2 probe failed (%s: %s).",
type(ex).__name__,
ex,
)
if raise_on_error:
raise SlackV2ProbeClientError(
f"Slack v2 availability probe failed: {type(ex).__name__}: {ex}"
) from ex
return False
def get_user_avatar(email: str, client: WebClient = None) -> str:
client = client or get_slack_client()
try:
response = client.users_lookupByEmail(email=email)
except Exception as ex:
raise SlackClientError(f"Failed to lookup user by email: {email}") from ex
user = response.data.get("user")
if user is None:
raise SlackClientError("No user found with that email.")
profile = user.get("profile")
if profile is None:
raise SlackClientError("User found but no profile available.")
avatar_url = profile.get("image_192")
if avatar_url is None:
raise SlackClientError("Profile image is not available.")
return avatar_url