diff --git a/symphony/bdk/core/service/reaction/__init__.py b/symphony/bdk/core/service/reaction/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/symphony/bdk/core/service/reaction/reaction_service.py b/symphony/bdk/core/service/reaction/reaction_service.py new file mode 100644 index 00000000..50a70219 --- /dev/null +++ b/symphony/bdk/core/service/reaction/reaction_service.py @@ -0,0 +1,140 @@ +"""Service exposing emoji reactions on messages. + +Reactions are a Symphony Messaging feature that users rely on, but they are not +described in the published Agent or Pod API specification, so no generated +client covers them. This service calls the same pod endpoint the Symphony +clients use, following the precedent set by +:class:`symphony.bdk.core.service.message.multi_attachments_messages_api.MultiAttachmentsMessagesApi` +for endpoints the generated code does not reach. +""" + +import base64 +import binascii +import json +import time +from typing import Any, Dict, List, Optional +from urllib.parse import quote + +from symphony.bdk.core.auth.auth_session import AuthSession +from symphony.bdk.core.config.model.bdk_retry_config import BdkRetryConfig +from symphony.bdk.core.retry import retry +from symphony.bdk.gen.api_client import ApiClient + +REACTIONS_PATH = "/maestro/reactions/v1/message" + + +def to_padded_base64(message_id: str) -> str: + """Convert a message id to the padded, non URL-safe form reactions match on. + + Message ids are handed out URL-safe and unpadded elsewhere in the API, but + the reactions endpoint matches on the standard alphabet with padding. Ids + that are already in that form are returned unchanged. + + :param message_id: A message id in either encoding. + :return: The message id in padded standard base64. + """ + converted = message_id.replace("-", "+").replace("_", "/") + padded = converted + "=" * (-len(converted) % 4) + try: + base64.b64decode(padded, validate=True) + except (binascii.Error, ValueError) as exc: + raise ValueError(f"{message_id!r} is not a base64 message id") from exc + return padded + + +class ReactionService: + """Service class for adding, removing and listing emoji reactions on messages. + + Reactions may be disabled for a pod, or restricted in external and federated + rooms, in which case these calls fail with the pod's own error. + """ + + def __init__( + self, pod_client: ApiClient, auth_session: AuthSession, retry_config: BdkRetryConfig + ): + self._pod_client = pod_client + self._auth_session = auth_session + self._retry_config = retry_config + + async def _call( + self, method: str, path: str, body: Optional[Dict[str, Any]] = None + ) -> Optional[Any]: + headers = { + "Accept": "application/json", + "sessionToken": await self._auth_session.session_token, + } + if body is not None: + headers["Content-Type"] = "application/json" + + response = await self._pod_client.call_api( + method, + self._pod_client.configuration.host + path, + header_params=headers, + body=json.dumps(body) if body is not None else None, + ) + data = getattr(response, "data", None) + if not data: + return None + if isinstance(data, bytes): + data = data.decode("utf-8") + try: + return json.loads(data) + except ValueError: + return None + + @retry + async def react(self, message_id: str, emoji: str) -> None: + """Add a reaction to a message on behalf of the calling user. + + Reacting twice with the same emoji leaves a single reaction. + + :param message_id: Id of the message to react to. + :param emoji: The emoji character itself, such as ``"\\N{THUMBS UP SIGN}"``. + Shortnames such as ``"thumbsup"`` are rejected by the pod. + """ + if not emoji or not emoji.strip(): + raise ValueError("emoji is required; the pod rejects an empty reaction") + await self._call( + "POST", + REACTIONS_PATH, + { + "messageId": to_padded_base64(message_id), + "emoji": emoji, + "timestamp": int(time.time() * 1000), + }, + ) + + @retry + async def unreact(self, message_id: str) -> None: + """Remove the calling user's reaction from a message. + + The emoji is deliberately omitted: the pod rejects an empty string, and + reacting again with the same emoji is a no-op rather than a toggle, so + leaving the field out is what clears the reaction. + + :param message_id: Id of the message to clear the reaction from. + """ + await self._call( + "POST", + REACTIONS_PATH, + { + "messageId": to_padded_base64(message_id), + "timestamp": int(time.time() * 1000), + }, + ) + + @retry + async def list_reactions(self, message_id: str) -> Dict[str, List[Dict[str, Any]]]: + """List the reactions on a message, keyed by emoji. + + :param message_id: Id of the message. For an edited message this is the + id of the original, which is what the reactions are attached to. + :return: A mapping of emoji to the reactions on it, each carrying the + reacting ``userId`` and a timestamp. Empty when there are none. + """ + encoded = quote(to_padded_base64(message_id), safe="") + payload = await self._call("GET", f"{REACTIONS_PATH}?initialMessageId={encoded}") + if not isinstance(payload, dict): + return {} + reactions = payload.get("reactions") + return reactions if isinstance(reactions, dict) else {} diff --git a/symphony/bdk/core/service_factory.py b/symphony/bdk/core/service_factory.py index e682d23f..ba2ba8d4 100644 --- a/symphony/bdk/core/service_factory.py +++ b/symphony/bdk/core/service_factory.py @@ -22,6 +22,7 @@ MultiAttachmentsMessagesApi, ) from symphony.bdk.core.service.presence.presence_service import OboPresenceService, PresenceService +from symphony.bdk.core.service.reaction.reaction_service import ReactionService from symphony.bdk.core.service.session.session_service import SessionService from symphony.bdk.core.service.signal.signal_service import OboSignalService, SignalService from symphony.bdk.core.service.stream.stream_service import OboStreamService, StreamService @@ -200,6 +201,13 @@ def get_presence_service(self) -> PresenceService: PresenceApi(self._pod_client), self._auth_session, self._config.retry ) + def get_reaction_service(self) -> ReactionService: + """Returns a fully initialized ReactionService + + :return: a new ReactionService instance + """ + return ReactionService(self._pod_client, self._auth_session, self._config.retry) + def get_agent_version_service(self) -> AgentVersionService: """Returns a fully initialized AgentVersionService diff --git a/symphony/bdk/core/symphony_bdk.py b/symphony/bdk/core/symphony_bdk.py index 2b9b3186..3c243cd6 100644 --- a/symphony/bdk/core/symphony_bdk.py +++ b/symphony/bdk/core/symphony_bdk.py @@ -19,6 +19,7 @@ from symphony.bdk.core.service.message.message_service import MessageService from symphony.bdk.core.service.obo_services import OboServices from symphony.bdk.core.service.presence.presence_service import PresenceService +from symphony.bdk.core.service.reaction.reaction_service import ReactionService from symphony.bdk.core.service.session.session_service import SessionService from symphony.bdk.core.service.signal.signal_service import SignalService from symphony.bdk.core.service.stream.stream_service import StreamService @@ -98,6 +99,7 @@ def __init__(self, config): self._datahose_loop = None self._health_service = None self._presence_service = None + self._reaction_service = None self._activity_registry = None if self._config.bot.is_authentication_configured(): @@ -125,6 +127,7 @@ def _initialize_bot_services(self): self._datahose_loop = self._service_factory.get_datahose_loop() self._health_service = self._service_factory.get_health_service() self._presence_service = self._service_factory.get_presence_service() + self._reaction_service = self._service_factory.get_reaction_service() # creates ActivityRegistry that subscribes to DF Loop events self._activity_registry = ActivityRegistry(self._session_service) self._datafeed_loop.subscribe(self._activity_registry) @@ -262,6 +265,14 @@ def health(self) -> HealthService: """ return self._health_service + @bot_service + def reactions(self) -> ReactionService: + """Get the ReactionService from the BDK entry point. + + :return: The ReactionService instance. + """ + return self._reaction_service + @bot_service def presence(self) -> PresenceService: """Get the PresenceService from the BDK entry point. diff --git a/tests/core/service/reaction/__init__.py b/tests/core/service/reaction/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/core/service/reaction/reaction_service_test.py b/tests/core/service/reaction/reaction_service_test.py new file mode 100644 index 00000000..398564b4 --- /dev/null +++ b/tests/core/service/reaction/reaction_service_test.py @@ -0,0 +1,109 @@ +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from symphony.bdk.core.auth.auth_session import AuthSession +from symphony.bdk.core.service.reaction.reaction_service import ( + REACTIONS_PATH, + ReactionService, + to_padded_base64, +) +from symphony.bdk.gen.api_client import ApiClient +from tests.core.config import minimal_retry_config + +URL_SAFE_ID = "XiIv3APOCgkFmgERKMwe8n___l7sy8bgbQ" +PADDED_ID = "XiIv3APOCgkFmgERKMwe8n///l7sy8bgbQ==" + + +@pytest.fixture(name="auth_session") +def fixture_auth_session(): + bot_session = AuthSession(None) + bot_session.session_token = "session_token" + bot_session.key_manager_token = "km_token" + return bot_session + + +@pytest.fixture(name="mocked_pod_client") +def fixture_mocked_pod_client(): + api_client = MagicMock(ApiClient) + api_client.configuration = MagicMock() + api_client.configuration.host = "https://acme.symphony.com" + api_client.call_api = AsyncMock(return_value=MagicMock(data=None)) + return api_client + + +@pytest.fixture(name="reaction_service") +def fixture_reaction_service(mocked_pod_client, auth_session): + return ReactionService(mocked_pod_client, auth_session, minimal_retry_config()) + + +def last_call(mocked_pod_client): + args, kwargs = mocked_pod_client.call_api.call_args + body = kwargs.get("body") + return args[0], args[1], kwargs.get("header_params"), json.loads(body) if body else None + + +class TestToPaddedBase64: + def test_url_safe_id_is_converted(self): + assert to_padded_base64(URL_SAFE_ID) == PADDED_ID + + def test_already_padded_id_is_unchanged(self): + assert to_padded_base64(PADDED_ID) == PADDED_ID + + def test_non_base64_is_rejected(self): + with pytest.raises(ValueError): + to_padded_base64("not base64 at all!!") + + +@pytest.mark.asyncio +async def test_react_posts_emoji_and_padded_id(reaction_service, mocked_pod_client): + await reaction_service.react(URL_SAFE_ID, "\N{THUMBS UP SIGN}") + + method, url, headers, body = last_call(mocked_pod_client) + assert method == "POST" + assert url == "https://acme.symphony.com" + REACTIONS_PATH + assert headers["sessionToken"] == "session_token" + assert body["messageId"] == PADDED_ID + assert body["emoji"] == "\N{THUMBS UP SIGN}" + assert isinstance(body["timestamp"], int) + + +@pytest.mark.asyncio +async def test_react_rejects_an_empty_emoji(reaction_service, mocked_pod_client): + for empty in ("", " "): + with pytest.raises(ValueError): + await reaction_service.react(URL_SAFE_ID, empty) + mocked_pod_client.call_api.assert_not_called() + + +@pytest.mark.asyncio +async def test_unreact_omits_the_emoji_field(reaction_service, mocked_pod_client): + """The pod rejects an empty emoji, so clearing has to leave the field out.""" + await reaction_service.unreact(URL_SAFE_ID) + + method, _, _, body = last_call(mocked_pod_client) + assert method == "POST" + assert "emoji" not in body + assert body["messageId"] == PADDED_ID + + +@pytest.mark.asyncio +async def test_list_reactions_returns_the_mapping(reaction_service, mocked_pod_client): + payload = {"reactions": {"\N{THUMBS UP SIGN}": [{"userId": 1234, "ts": 1}]}, "sequenceNb": 1} + mocked_pod_client.call_api = AsyncMock(return_value=MagicMock(data=json.dumps(payload))) + + reactions = await reaction_service.list_reactions(URL_SAFE_ID) + + assert reactions == payload["reactions"] + method, url, _, body = last_call(mocked_pod_client) + assert method == "GET" + assert body is None + assert "initialMessageId=XiIv3APOCgkFmgERKMwe8n%2F%2F%2Fl7sy8bgbQ%3D%3D" in url + + +@pytest.mark.asyncio +async def test_list_reactions_handles_an_empty_response(reaction_service, mocked_pod_client): + mocked_pod_client.call_api = AsyncMock(return_value=MagicMock(data=None)) + + assert await reaction_service.list_reactions(URL_SAFE_ID) == {}