Skip to content

Commit

Permalink
Add slcak module from lvm-api
Browse files Browse the repository at this point in the history
  • Loading branch information
albireox committed Dec 21, 2024
1 parent 9cebf08 commit edf2715
Show file tree
Hide file tree
Showing 7 changed files with 531 additions and 2 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
- name: Test with pytest
run: |
uv pip install pytest pytest-mock pytest-asyncio pytest-cov
uv pip install pytest pytest-mock pytest-asyncio pytest-cov pytest-rabbitmq pytest-dev
uv run pytest
env:
PYTEST_RABBITMQ_CTL: '/usr/lib/rabbitmq/bin/rabbitmqctl'
Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ dependencies = [
"aio-pika>=9.5.3",
"pydantic>=2.10.3",
"strenum>=0.4.15",
"aiocache>=0.12.3",
"slack-sdk>=3.34.0",
"aiohttp>=3.11.11",
]

[project.optional-dependencies]
Expand Down Expand Up @@ -65,6 +68,7 @@ dev-dependencies = [
"sphinx-autodoc-typehints>=1.23.2",
"ruff>=0.6.1",
"pytest-rabbitmq>=3.1.1",
"pytest-env>=1.1.5",
]

[tool.ruff]
Expand Down Expand Up @@ -92,6 +96,10 @@ addopts = "--cov lvmopstools --cov-report xml --cov-report html --cov-report ter
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = 'function'

[tool.pytest_env]
AIOCACHE_DISABLE = "1"
SLACK_API_TOKEN = "test-token"

[tool.coverage.run]
branch = true
include = ["src/lvmopstools/*"]
Expand Down
6 changes: 6 additions & 0 deletions src/lvmopstools/data/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ rabbitmq:

api: http://10.8.38.21:8090/api

slack:
token: null
default_channel: lvm-notifications
level_channels:
CRITICAL: lvm-alerts

pubsub:
connection_string: amqp://guest:guest@localhost:5672
exchange_name: lvmops
Expand Down
159 changes: 159 additions & 0 deletions src/lvmopstools/slack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# @Author: José Sánchez-Gallego ([email protected])
# @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 slack_sdk.errors import SlackApiError
from slack_sdk.web.async_client import AsyncWebClient

from lvmopstools import config


__all__ = ["post_message", "get_user_id"]


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."""

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


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()

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']}")


@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()

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']}")


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.")
4 changes: 3 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ def setup_rabbitmq(port: int | None = None):


@pytest.fixture(scope="session", autouse=True)
def monkeypatch_config(tmp_path_factory: pytest.TempPathFactory):
def monkeypatch_config(
tmp_path_factory: pytest.TempPathFactory,
):
# Replace the placeholder in the test config file with the actual RMQ port.
test_config_path = pathlib.Path(__file__).parent / "data" / "test_config.yaml"
test_config = test_config_path.read_text()
Expand Down
120 changes: 120 additions & 0 deletions tests/test_slack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# @Author: José Sánchez-Gallego ([email protected])
# @Date: 2024-12-21
# @Filename: test_slack.py
# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause)

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest

import lvmopstools.slack
from lvmopstools.slack import ICONS, post_message


if TYPE_CHECKING:
from pytest_mock import MockerFixture


@pytest.fixture()
def mock_slack(mocker: MockerFixture):
yield mocker.patch.object(lvmopstools.slack, "AsyncWebClient", autospec=True)


async def test_post_message(mock_slack):
await post_message("@here This is a test", channel="test")

mock_slack.return_value.chat_postMessage.assert_called_with(
channel="test",
text="<!here> This is a test",
blocks=None,
icon_url=None,
username=None,
)


async def test_post_message_with_icon(mock_slack):
await post_message(
"This is a test",
channel="test",
username="Overwatcher",
)

mock_slack.return_value.chat_postMessage.assert_called_with(
channel="test",
text="This is a test",
blocks=None,
icon_url=ICONS["overwatcher"],
username="Overwatcher",
)


async def test_post_message_with_user(mock_slack):
mock_slack.return_value.users_list.return_value = {
"members": [
{
"id": "U01ABC123",
"name": "user1",
"profile": {
"real_name": "User Name",
"display_name": "user1",
"display_name_normalized": "user1",
},
}
],
"ok": True,
}

await post_message(
"This is a test",
channel="test",
mentions=["user1"],
)

mock_slack.return_value.chat_postMessage.assert_called_with(
channel="test",
text="<@U01ABC123> This is a test",
blocks=None,
icon_url=None,
username=None,
)


async def test_post_message_raises(mock_slack):
mock_slack.return_value.chat_postMessage.side_effect = (
lvmopstools.slack.SlackApiError("test error", response={"error": "test error"})
)

with pytest.raises(RuntimeError):
await post_message("This is a test", channel="test")


async def test_invalid_users_list(mock_slack):
mock_slack.return_value.users_list.return_value = {"ok": False}

with pytest.raises(RuntimeError):
await lvmopstools.slack.get_user_list()


async def test_user_id_not_found(mock_slack):
mock_slack.return_value.users_list.return_value = {
"members": [
{
"id": "U01ABC123",
"name": "user1",
"profile": {
"real_name": "User Name",
"display_name": "user1",
"display_name_normalized": "user1",
},
}
],
"ok": True,
}

with pytest.raises(NameError):
await lvmopstools.slack.get_user_id("user2")
Loading

0 comments on commit edf2715

Please sign in to comment.