Skip to content
Draft
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
Empty file.
140 changes: 140 additions & 0 deletions symphony/bdk/core/service/reaction/reaction_service.py
Original file line number Diff line number Diff line change
@@ -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 {}
8 changes: 8 additions & 0 deletions symphony/bdk/core/service_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions symphony/bdk/core/symphony_bdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
Empty file.
109 changes: 109 additions & 0 deletions tests/core/service/reaction/reaction_service_test.py
Original file line number Diff line number Diff line change
@@ -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) == {}
Loading