Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add slack module from lvm-api #11

Merged
merged 3 commits into from
Dec 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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-env
uv run pytest
env:
PYTEST_RABBITMQ_CTL: '/usr/lib/rabbitmq/bin/rabbitmqctl'
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### 🚀 New

* [#10](https://vscode.dev/github/sdss/lvmopstools/pull/10) Added a `pubsub` module with tools to emit and subscribe to events using RabbitMQ.
* [#11](https://vscode.dev/github/sdss/lvmopstools/pull/11) Added a `slack` module with tools to send messages to Slack.


## 0.4.4 - December 5, 2024
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

Check warning on line 43 in src/lvmopstools/slack.py

View check run for this annotation

Codecov / codecov/patch

src/lvmopstools/slack.py#L43

Added line #L43 was not covered by tests

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

Check warning on line 63 in src/lvmopstools/slack.py

View check run for this annotation

Codecov / codecov/patch

src/lvmopstools/slack.py#L62-L63

Added lines #L62 - L63 were not covered by tests
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.")

Check warning on line 95 in src/lvmopstools/slack.py

View check run for this annotation

Codecov / codecov/patch

src/lvmopstools/slack.py#L95

Added line #L95 was not covered by tests

if text is not None and blocks is not None:
raise ValueError("Cannot specify both text and blocks.")

Check warning on line 98 in src/lvmopstools/slack.py

View check run for this annotation

Codecov / codecov/patch

src/lvmopstools/slack.py#L98

Added line #L98 was not covered by tests

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

Check warning on line 151 in src/lvmopstools/slack.py

View check run for this annotation

Codecov / codecov/patch

src/lvmopstools/slack.py#L151

Added line #L151 was not covered by tests

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
Loading