diff --git a/.gitignore b/.gitignore index 80b31d3..449f335 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ docs/build/ Pipfile .idea docs/source/_build +.mypy_cache diff --git a/src/galaxy/api/consts.py b/src/galaxy/api/consts.py index 47e0dbc..8881868 100644 --- a/src/galaxy/api/consts.py +++ b/src/galaxy/api/consts.py @@ -113,6 +113,7 @@ class Feature(Enum): LaunchPlatformClient = "LaunchPlatformClient" ImportGameLibrarySettings = "ImportGameLibrarySettings" ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" class LicenseType(Enum): @@ -140,3 +141,11 @@ class OSCompatibility(Flag): Windows = 0b001 MacOS = 0b010 Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" diff --git a/src/galaxy/api/plugin.py b/src/galaxy/api/plugin.py index 78a89e5..e8ae655 100644 --- a/src/galaxy/api/plugin.py +++ b/src/galaxy/api/plugin.py @@ -10,7 +10,9 @@ from typing import Any, Dict, List, Optional, Set, Union from galaxy.api.consts import Feature, OSCompatibility from galaxy.api.errors import ImportInProgress, UnknownError from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep, GameLibrarySettings +from galaxy.api.types import ( + Achievement, Authentication, FriendInfo, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserPresence +) from galaxy.task_manager import TaskManager @@ -49,6 +51,7 @@ class Plugin: self._game_times_import_in_progress = False self._game_library_settings_import_in_progress = False self._os_compatibility_import_in_progress = False + self._user_presence_import_in_progress = False self._persistent_cache = dict() @@ -118,6 +121,9 @@ class Plugin: self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + async def __aenter__(self): return self @@ -265,7 +271,7 @@ class Plugin: """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore self._notification_client.notify("store_credentials", credentials, sensitive_params=True) @@ -450,6 +456,27 @@ class Plugin: def _os_compatibility_import_finished(self) -> None: self._notification_client.notify("os_compatibility_import_finished", None) + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._notification_client.notify( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._notification_client.notify( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._notification_client.notify("user_presence_import_finished", None) + def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. @@ -912,6 +939,62 @@ class Plugin: def os_compatibility_import_complete(self) -> None: """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + async def _start_user_presence_import(self, user_ids: List[str]) -> None: + if self._user_presence_import_in_progress: + raise ImportInProgress() + + context = await self.prepare_user_presence_context(user_ids) + + async def import_user_presence(user_id, context_) -> None: + try: + self._user_presence_import_success(user_id, await self.get_user_presence(user_id, context_)) + except ApplicationError as error: + self._user_presence_import_failure(user_id, error) + except Exception: + logging.exception("Unexpected exception raised in import_user_presence") + self._user_presence_import_failure(user_id, UnknownError()) + + async def import_user_presence_set(user_ids_, context_) -> None: + try: + await asyncio.gather(*[ + import_user_presence(user_id, context_) + for user_id in user_ids_ + ]) + finally: + self._user_presence_import_finished() + self._user_presence_import_in_progress = False + self.user_presence_import_complete() + + self._external_task_manager.create_task( + import_user_presence_set(user_ids, context), + "user presence import", + handle_exceptions=False + ) + self._user_presence_import_in_progress = True + + async def prepare_user_presence_context(self, user_ids: List[str]) -> Any: + """Override this method to prepare context for get_user_presence. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_ids: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. diff --git a/src/galaxy/api/types.py b/src/galaxy/api/types.py index aeefe0b..6c0a71b 100644 --- a/src/galaxy/api/types.py +++ b/src/galaxy/api/types.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Dict, List, Optional -from galaxy.api.consts import LicenseType, LocalGameState +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState @dataclass @@ -174,3 +174,18 @@ class GameLibrarySettings: game_id: str tags: Optional[List[str]] hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param presence_status: detailed user's presence description + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + presence_status: Optional[str] = None diff --git a/tests/conftest.py b/tests/conftest.py index 97b0ffe..fd01d5d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,33 +1,38 @@ -from contextlib import ExitStack import logging -from unittest.mock import patch, MagicMock +from contextlib import ExitStack +from unittest.mock import MagicMock, patch import pytest -from galaxy.api.plugin import Plugin from galaxy.api.consts import Platform +from galaxy.api.plugin import Plugin from galaxy.unittest.mock import async_return_value + @pytest.fixture() def reader(): stream = MagicMock(name="stream_reader") stream.read = MagicMock() yield stream + @pytest.fixture() async def writer(): stream = MagicMock(name="stream_writer") stream.drain.side_effect = lambda: async_return_value(None) yield stream + @pytest.fixture() def read(reader): yield reader.read + @pytest.fixture() def write(writer): yield writer.write + @pytest.fixture() async def plugin(reader, writer): """Return plugin instance with all feature methods mocked""" @@ -56,6 +61,9 @@ async def plugin(reader, writer): "get_os_compatibility", "prepare_os_compatibility_context", "os_compatibility_import_complete", + "get_user_presence", + "prepare_user_presence_context", + "user_presence_import_complete", ) with ExitStack() as stack: diff --git a/tests/test_features.py b/tests/test_features.py index 3d669ab..cf2922e 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -16,7 +16,8 @@ def test_base_class(): Feature.ShutdownPlatformClient, Feature.LaunchPlatformClient, Feature.ImportGameLibrarySettings, - Feature.ImportOSCompatibility + Feature.ImportOSCompatibility, + Feature.ImportUserPresence } diff --git a/tests/test_user_presence.py b/tests/test_user_presence.py new file mode 100644 index 0000000..67163e7 --- /dev/null +++ b/tests/test_user_presence.py @@ -0,0 +1,231 @@ +from unittest.mock import call + +import pytest + +from galaxy.api.consts import PresenceState +from galaxy.api.errors import BackendError +from galaxy.api.types import UserPresence +from galaxy.unittest.mock import async_return_value +from tests import create_message, get_messages + + +@pytest.mark.asyncio +async def test_get_user_presence_success(plugin, read, write): + context = "abc" + user_ids = ["666", "13", "42", "69"] + plugin.prepare_user_presence_context.return_value = async_return_value(context) + request = { + "jsonrpc": "2.0", + "id": "11", + "method": "start_user_presence_import", + "params": {"user_ids": user_ids} + } + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] + plugin.get_user_presence.side_effect = [ + async_return_value(UserPresence( + PresenceState.Unknown, + "game-id1", + None, + "unknown state" + )), + async_return_value(UserPresence( + PresenceState.Offline, + None, + None, + "Going to grandma's house" + )), + async_return_value(UserPresence( + PresenceState.Online, + "game-id3", + "game-title3", + "Pew pew" + )), + async_return_value(UserPresence( + PresenceState.Away, + None, + "game-title4", + "AFKKTHXBY" + )), + ] + await plugin.run() + plugin.get_user_presence.assert_has_calls([ + call(user_id, context) for user_id in user_ids + ]) + plugin.user_presence_import_complete.assert_called_once_with() + + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": "11", + "result": None + }, + { + "jsonrpc": "2.0", + "method": "user_presence_import_success", + "params": { + "user_id": "666", + "presence": { + "presence_state": PresenceState.Unknown.value, + "game_id": "game-id1", + "presence_status": "unknown state" + } + } + }, + { + "jsonrpc": "2.0", + "method": "user_presence_import_success", + "params": { + "user_id": "13", + "presence": { + "presence_state": PresenceState.Offline.value, + "presence_status": "Going to grandma's house" + } + } + }, + { + "jsonrpc": "2.0", + "method": "user_presence_import_success", + "params": { + "user_id": "42", + "presence": { + "presence_state": PresenceState.Online.value, + "game_id": "game-id3", + "game_title": "game-title3", + "presence_status": "Pew pew" + } + } + }, + { + "jsonrpc": "2.0", + "method": "user_presence_import_success", + "params": { + "user_id": "69", + "presence": { + "presence_state": PresenceState.Away.value, + "game_title": "game-title4", + "presence_status": "AFKKTHXBY" + } + } + }, + { + "jsonrpc": "2.0", + "method": "user_presence_import_finished", + "params": None + } + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("exception,code,message", [ + (BackendError, 4, "Backend error"), + (KeyError, 0, "Unknown error") +]) +async def test_get_user_presence_error(exception, code, message, plugin, read, write): + user_id = "69" + request_id = "55" + plugin.prepare_user_presence_context.return_value = async_return_value(None) + request = { + "jsonrpc": "2.0", + "id": request_id, + "method": "start_user_presence_import", + "params": {"user_ids": [user_id]} + } + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] + plugin.get_user_presence.side_effect = exception + await plugin.run() + plugin.get_user_presence.assert_called() + plugin.user_presence_import_complete.assert_called_once_with() + + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": request_id, + "result": None + }, + { + "jsonrpc": "2.0", + "method": "user_presence_import_failure", + "params": { + "user_id": user_id, + "error": { + "code": code, + "message": message + } + } + }, + { + "jsonrpc": "2.0", + "method": "user_presence_import_finished", + "params": None + } + ] + + +@pytest.mark.asyncio +async def test_prepare_get_user_presence_context_error(plugin, read, write): + request_id = "31415" + plugin.prepare_user_presence_context.side_effect = BackendError() + request = { + "jsonrpc": "2.0", + "id": request_id, + "method": "start_user_presence_import", + "params": {"user_ids": ["6"]} + } + read.side_effect = [async_return_value(create_message(request)), async_return_value(b"", 10)] + await plugin.run() + + assert get_messages(write) == [ + { + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": 4, + "message": "Backend error" + } + } + ] + + +@pytest.mark.asyncio +async def test_import_already_in_progress_error(plugin, read, write): + plugin.prepare_user_presence_context.return_value = async_return_value(None) + requests = [ + { + "jsonrpc": "2.0", + "id": "3", + "method": "start_user_presence_import", + "params": { + "user_ids": ["42"] + } + }, + { + "jsonrpc": "2.0", + "id": "4", + "method": "start_user_presence_import", + "params": { + "user_ids": ["666"] + } + } + ] + read.side_effect = [ + async_return_value(create_message(requests[0])), + async_return_value(create_message(requests[1])), + async_return_value(b"", 10) + ] + + await plugin.run() + + responses = get_messages(write) + assert { + "jsonrpc": "2.0", + "id": "3", + "result": None + } in responses + assert { + "jsonrpc": "2.0", + "id": "4", + "error": { + "code": 600, + "message": "Import already in progress" + } + } in responses