From ebe17e0506089af2094a836847356b438a3a5853 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:26:16 -0400 Subject: [PATCH] Add ReactionService for emoji reactions on messages Emoji reactions are a Symphony Messaging feature that users rely on, but they are absent from the published Agent and Pod API specifications, so no generated client covers them and bots have had no way to participate. ReactionService calls the same pod endpoint the Symphony clients use, POST and GET on /maestro/reactions/v1/message, and is reachable as bdk.reactions(). It follows the precedent of MultiAttachmentsMessagesApi, which is hand written for an endpoint the generated code does not reach. Three behaviours the endpoint enforces, each confirmed against a pod: - Message ids must be padded standard base64. Ids are handed out URL-safe and unpadded elsewhere, so to_padded_base64 converts them and rejects anything that is not base64. - The emoji must be the character itself. Shortnames and empty strings come back as REACTIONS_INVALID_EMOJI, so an empty one is refused before the request is made. - Clearing a reaction omits the emoji field rather than sending an empty one, since an empty string is rejected and reacting twice with the same emoji is idempotent rather than a toggle. Reactions can be disabled per pod, and restricted in external or federated rooms, in which case these calls surface the pod's own error. --- .../bdk/core/service/reaction/__init__.py | 0 .../core/service/reaction/reaction_service.py | 140 ++++++++++++++++++ symphony/bdk/core/service_factory.py | 8 + symphony/bdk/core/symphony_bdk.py | 11 ++ tests/core/service/reaction/__init__.py | 0 .../service/reaction/reaction_service_test.py | 109 ++++++++++++++ 6 files changed, 268 insertions(+) create mode 100644 symphony/bdk/core/service/reaction/__init__.py create mode 100644 symphony/bdk/core/service/reaction/reaction_service.py create mode 100644 tests/core/service/reaction/__init__.py create mode 100644 tests/core/service/reaction/reaction_service_test.py 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) == {}