Source code for lvmopstools.slack

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# @Author: José Sánchez-Gallego (gallegoj@uw.edu)
# @Date: 2023-11-12
# @Filename: slack.py
# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause)

from __future__ import annotations

import os
import re

from typing import Sequence

from aiocache import cached

from lvmopstools import config


try:
    from slack_sdk.errors import SlackApiError
    from slack_sdk.web.async_client import AsyncWebClient
except ImportError:
    AsyncWebClient = None
    SlackApiError = None

__all__ = ["post_message", "get_user_id", "get_user_list"]


ICONS = {
    "overwatcher": "https://github.com/sdss/lvmgort/blob/main/docs/sphinx/_static/gort_logo_slack.png?raw=true"
}


def get_api_client(token: str | None = None):
    """Gets a Slack API client."""

    if AsyncWebClient is None:
        raise ImportError(
            "slack-sdk is not installed. Install the slack or all extras."
        )

    token = token or config["slack.token"] or os.environ["SLACK_API_TOKEN"]

    return AsyncWebClient(token=token)


async def format_mentions(text: str | None, mentions: list[str]) -> str | None:
    """Formats a text message with mentions."""

    if not text:
        return text

    if len(mentions) > 0:
        for mention in mentions[::-1]:
            if mention[0] != "@":
                mention = f"@{mention}"
            if mention not in text:
                text = f"{mention} {text}"

    # Replace @channel, @here, ... with the API format <!here>.
    text = re.sub(r"(\s|^)@(here|channel|everone)(\s|$)", r"\1<!here>\3", text)

    # The remaining mentions should be users. But in the API these need to be
    # <@XXXX> where XXXX is the user ID and not the username.
    users: list[str] = re.findall(r"(?:\s|^)@([a-zA-Z_0-9]+)(?:\s|$)", text)

    for user in users:
        try:
            user_id = await get_user_id(user)
        except NameError:
            continue
        text = text.replace(f"@{user}", f"<@{user_id}>")

    return text


[docs] async def post_message( text: str | None = None, blocks: Sequence[dict] | None = None, channel: str | None = None, mentions: list[str] = [], username: str | None = None, icon_url: str | None = None, **kwargs, ): """Posts a message to Slack. Parameters ---------- text Plain text to send to the Slack channel. blocks A list of blocks to send to the Slack channel. These follow the Slack API format for blocks. Incompatible with ``text``. channel The channel in the SSDS-V workspace where to send the message. mentions A list of users to mention in the message. """ if text is None and blocks is None: raise ValueError("Must specify either text or blocks.") if text is not None and blocks is not None: raise ValueError("Cannot specify both text and blocks.") channel = channel or config["slack.default_channel"] assert channel is not None if username is not None and icon_url is None and username.lower() in ICONS: icon_url = ICONS[username.lower()] client = get_api_client() assert SlackApiError is not None try: text = await format_mentions(text, mentions) await client.chat_postMessage( channel=channel, text=text, blocks=blocks, username=username, icon_url=icon_url, **kwargs, ) except SlackApiError as e: raise RuntimeError(f"Slack returned an error: {e.response['error']}")
[docs] @cached(ttl=120) async def get_user_list(): """Returns the list of users in the workspace. This function is cached because Slack limits the requests for this route. """ client = get_api_client() assert SlackApiError is not None try: users_list = await client.users_list() if users_list["ok"] is False: err = "users_list returned ok=false" raise SlackApiError(err, response={"error": err}) return users_list except SlackApiError as e: raise RuntimeError(f"Slack returned an error: {e.response['error']}")
[docs] async def get_user_id(name: str): """Gets the ``userID`` of the user display name matches ``name``.""" users_list = await get_user_list() for member in users_list["members"]: if "profile" not in member or "display_name" not in member["profile"]: continue if ( member["profile"]["display_name"] == name or member["profile"]["display_name_normalized"] == name ): return member["id"] raise NameError(f"User {name} not found.")