Change project layout

This commit is contained in:
Romuald Juchnowicz-Bierbasz
2019-05-10 13:16:28 +02:00
parent 9e1c8cfddd
commit 90835ece58
12 changed files with 3 additions and 1 deletions

View File

44
src/galaxy/api/consts.py Normal file
View File

@@ -0,0 +1,44 @@
from enum import Enum, Flag
class Platform(Enum):
Unknown = "unknown"
Gog = "gog"
Steam = "steam"
Psn = "psn"
XBoxOne = "xboxone"
Generic = "generic"
Origin = "origin"
Uplay = "uplay"
Battlenet = "battlenet"
Epic = "epic"
class Feature(Enum):
Unknown = "Unknown"
ImportInstalledGames = "ImportInstalledGames"
ImportOwnedGames = "ImportOwnedGames"
LaunchGame = "LaunchGame"
InstallGame = "InstallGame"
UninstallGame = "UninstallGame"
ImportAchievements = "ImportAchievements"
ImportGameTime = "ImportGameTime"
Chat = "Chat"
ImportUsers = "ImportUsers"
VerifyGame = "VerifyGame"
ImportFriends = "ImportFriends"
class LicenseType(Enum):
Unknown = "Unknown"
SinglePurchase = "SinglePurchase"
FreeToPlay = "FreeToPlay"
OtherUserLicense = "OtherUserLicense"
class LocalGameState(Flag):
None_ = 0
Installed = 1
Running = 2
class PresenceState(Enum):
Unknown = "Unknown"
Online = "online"
Offline = "offline"
Away = "away"

79
src/galaxy/api/errors.py Normal file
View File

@@ -0,0 +1,79 @@
from galaxy.api.jsonrpc import ApplicationError, UnknownError
UnknownError = UnknownError
class AuthenticationRequired(ApplicationError):
def __init__(self, data=None):
super().__init__(1, "Authentication required", data)
class BackendNotAvailable(ApplicationError):
def __init__(self, data=None):
super().__init__(2, "Backend not available", data)
class BackendTimeout(ApplicationError):
def __init__(self, data=None):
super().__init__(3, "Backend timed out", data)
class BackendError(ApplicationError):
def __init__(self, data=None):
super().__init__(4, "Backend error", data)
class UnknownBackendResponse(ApplicationError):
def __init__(self, data=None):
super().__init__(4, "Backend responded in uknown way", data)
class InvalidCredentials(ApplicationError):
def __init__(self, data=None):
super().__init__(100, "Invalid credentials", data)
class NetworkError(ApplicationError):
def __init__(self, data=None):
super().__init__(101, "Network error", data)
class LoggedInElsewhere(ApplicationError):
def __init__(self, data=None):
super().__init__(102, "Logged in elsewhere", data)
class ProtocolError(ApplicationError):
def __init__(self, data=None):
super().__init__(103, "Protocol error", data)
class TemporaryBlocked(ApplicationError):
def __init__(self, data=None):
super().__init__(104, "Temporary blocked", data)
class Banned(ApplicationError):
def __init__(self, data=None):
super().__init__(105, "Banned", data)
class AccessDenied(ApplicationError):
def __init__(self, data=None):
super().__init__(106, "Access denied", data)
class ParentalControlBlock(ApplicationError):
def __init__(self, data=None):
super().__init__(107, "Parental control block", data)
class DeviceBlocked(ApplicationError):
def __init__(self, data=None):
super().__init__(108, "Device blocked", data)
class RegionBlocked(ApplicationError):
def __init__(self, data=None):
super().__init__(109, "Region blocked", data)
class FailedParsingManifest(ApplicationError):
def __init__(self, data=None):
super().__init__(200, "Failed parsing manifest", data)
class TooManyMessagesSent(ApplicationError):
def __init__(self, data=None):
super().__init__(300, "Too many messages sent", data)
class IncoherentLastMessage(ApplicationError):
def __init__(self, data=None):
super().__init__(400, "Different last message id on backend", data)
class MessageNotFound(ApplicationError):
def __init__(self, data=None):
super().__init__(500, "Message not found", data)

282
src/galaxy/api/jsonrpc.py Normal file
View File

@@ -0,0 +1,282 @@
import asyncio
from collections import namedtuple
from collections.abc import Iterable
import logging
import inspect
import json
class JsonRpcError(Exception):
def __init__(self, code, message, data=None):
self.code = code
self.message = message
self.data = data
super().__init__()
class ParseError(JsonRpcError):
def __init__(self):
super().__init__(-32700, "Parse error")
class InvalidRequest(JsonRpcError):
def __init__(self):
super().__init__(-32600, "Invalid Request")
class MethodNotFound(JsonRpcError):
def __init__(self):
super().__init__(-32601, "Method not found")
class InvalidParams(JsonRpcError):
def __init__(self):
super().__init__(-32602, "Invalid params")
class Timeout(JsonRpcError):
def __init__(self):
super().__init__(-32000, "Method timed out")
class Aborted(JsonRpcError):
def __init__(self):
super().__init__(-32001, "Method aborted")
class ApplicationError(JsonRpcError):
def __init__(self, code, message, data):
if code >= -32768 and code <= -32000:
raise ValueError("The error code in reserved range")
super().__init__(code, message, data)
class UnknownError(ApplicationError):
def __init__(self, data=None):
super().__init__(0, "Unknown error", data)
Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None])
Method = namedtuple("Method", ["callback", "signature", "internal", "sensitive_params"])
def anonymise_sensitive_params(params, sensitive_params):
anomized_data = "****"
if not sensitive_params:
return params
if isinstance(sensitive_params, Iterable):
anomized_params = params.copy()
for key in anomized_params.keys():
if key in sensitive_params:
anomized_params[key] = anomized_data
return anomized_params
return anomized_data
class Server():
def __init__(self, reader, writer, encoder=json.JSONEncoder()):
self._active = True
self._reader = reader
self._writer = writer
self._encoder = encoder
self._methods = {}
self._notifications = {}
self._eof_listeners = []
def register_method(self, name, callback, internal, sensitive_params=False):
"""
Register method
:param name:
:param callback:
:param internal: if True the callback will be processed immediately (synchronously)
:param sensitive_params: list of parameters that will by anonymized before logging; if False - no params
are considered sensitive, if True - all params are considered sensitive
"""
self._methods[name] = Method(callback, inspect.signature(callback), internal, sensitive_params)
def register_notification(self, name, callback, internal, sensitive_params=False):
"""
Register notification
:param name:
:param callback:
:param internal: if True the callback will be processed immediately (synchronously)
:param sensitive_params: list of parameters that will by anonymized before logging; if False - no params
are considered sensitive, if True - all params are considered sensitive
"""
self._notifications[name] = Method(callback, inspect.signature(callback), internal, sensitive_params)
def register_eof(self, callback):
self._eof_listeners.append(callback)
async def run(self):
while self._active:
try:
data = await self._reader.readline()
if not data:
self._eof()
continue
except:
self._eof()
continue
data = data.strip()
logging.debug("Received %d bytes of data", len(data))
self._handle_input(data)
def stop(self):
self._active = False
def _eof(self):
logging.info("Received EOF")
self.stop()
for listener in self._eof_listeners:
listener()
def _handle_input(self, data):
try:
request = self._parse_request(data)
except JsonRpcError as error:
self._send_error(None, error)
return
if request.id is not None:
self._handle_request(request)
else:
self._handle_notification(request)
def _handle_notification(self, request):
method = self._notifications.get(request.method)
if not method:
logging.error("Received unknown notification: %s", request.method)
return
callback, signature, internal, sensitive_params = method
self._log_request(request, sensitive_params)
try:
bound_args = signature.bind(**request.params)
except TypeError:
self._send_error(request.id, InvalidParams())
if internal:
# internal requests are handled immediately
callback(*bound_args.args, **bound_args.kwargs)
else:
try:
asyncio.create_task(callback(*bound_args.args, **bound_args.kwargs))
except Exception:
logging.exception("Unexpected exception raised in notification handler")
def _handle_request(self, request):
method = self._methods.get(request.method)
if not method:
logging.error("Received unknown request: %s", request.method)
self._send_error(request.id, MethodNotFound())
return
callback, signature, internal, sensitive_params = method
self._log_request(request, sensitive_params)
try:
bound_args = signature.bind(**request.params)
except TypeError:
self._send_error(request.id, InvalidParams())
if internal:
# internal requests are handled immediately
response = callback(*bound_args.args, **bound_args.kwargs)
self._send_response(request.id, response)
else:
async def handle():
try:
result = await callback(*bound_args.args, **bound_args.kwargs)
self._send_response(request.id, result)
except NotImplementedError:
self._send_error(request.id, MethodNotFound())
except JsonRpcError as error:
self._send_error(request.id, error)
except Exception as e: #pylint: disable=broad-except
logging.exception("Unexpected exception raised in plugin handler")
self._send_error(request.id, UnknownError(str(e)))
asyncio.create_task(handle())
@staticmethod
def _parse_request(data):
try:
jsonrpc_request = json.loads(data, encoding="utf-8")
if jsonrpc_request.get("jsonrpc") != "2.0":
raise InvalidRequest()
del jsonrpc_request["jsonrpc"]
return Request(**jsonrpc_request)
except json.JSONDecodeError:
raise ParseError()
except TypeError:
raise InvalidRequest()
def _send(self, data):
try:
line = self._encoder.encode(data)
logging.debug("Sending data: %s", line)
data = (line + "\n").encode("utf-8")
self._writer.write(data)
asyncio.create_task(self._writer.drain())
except TypeError as error:
logging.error(str(error))
def _send_response(self, request_id, result):
response = {
"jsonrpc": "2.0",
"id": request_id,
"result": result
}
self._send(response)
def _send_error(self, request_id, error):
response = {
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": error.code,
"message": error.message
}
}
if error.data is not None:
response["error"]["data"] = error.data
self._send(response)
@staticmethod
def _log_request(request, sensitive_params):
params = anonymise_sensitive_params(request.params, sensitive_params)
if request.id is not None:
logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params)
else:
logging.info("Handling notification: method=%s, params=%s", request.method, params)
class NotificationClient():
def __init__(self, writer, encoder=json.JSONEncoder()):
self._writer = writer
self._encoder = encoder
self._methods = {}
def notify(self, method, params, sensitive_params=False):
"""
Send notification
:param method:
:param params:
:param sensitive_params: list of parameters that will by anonymized before logging; if False - no params
are considered sensitive, if True - all params are considered sensitive
"""
notification = {
"jsonrpc": "2.0",
"method": method,
"params": params
}
self._log(method, params, sensitive_params)
self._send(notification)
def _send(self, data):
try:
line = self._encoder.encode(data)
data = (line + "\n").encode("utf-8")
logging.debug("Sending %d byte of data", len(data))
self._writer.write(data)
asyncio.create_task(self._writer.drain())
except TypeError as error:
logging.error("Failed to parse outgoing message: %s", str(error))
@staticmethod
def _log(method, params, sensitive_params):
params = anonymise_sensitive_params(params, sensitive_params)
logging.info("Sending notification: method=%s, params=%s", method, params)

348
src/galaxy/api/plugin.py Normal file
View File

@@ -0,0 +1,348 @@
import asyncio
import json
import logging
import logging.handlers
import dataclasses
from enum import Enum
from collections import OrderedDict
import sys
from galaxy.api.jsonrpc import Server, NotificationClient
from galaxy.api.consts import Feature
class JSONEncoder(json.JSONEncoder):
def default(self, o): # pylint: disable=method-hidden
if dataclasses.is_dataclass(o):
# filter None values
def dict_factory(elements):
return {k: v for k, v in elements if v is not None}
return dataclasses.asdict(o, dict_factory=dict_factory)
if isinstance(o, Enum):
return o.value
return super().default(o)
class Plugin():
def __init__(self, platform, version, reader, writer, handshake_token):
logging.info("Creating plugin for platform %s, version %s", platform.value, version)
self._platform = platform
self._version = version
self._feature_methods = OrderedDict()
self._active = True
self._reader, self._writer = reader, writer
self._handshake_token = handshake_token
encoder = JSONEncoder()
self._server = Server(self._reader, self._writer, encoder)
self._notification_client = NotificationClient(self._writer, encoder)
def eof_handler():
self._shutdown()
self._server.register_eof(eof_handler)
# internal
self._register_method("shutdown", self._shutdown, internal=True)
self._register_method("get_capabilities", self._get_capabilities, internal=True)
self._register_method("ping", self._ping, internal=True)
# implemented by developer
self._register_method("init_authentication", self.authenticate, sensitive_params=["stored_credentials"])
self._register_method("pass_login_credentials", self.pass_login_credentials)
self._register_method(
"import_owned_games",
self.get_owned_games,
result_name="owned_games",
feature=Feature.ImportOwnedGames
)
self._register_method(
"import_unlocked_achievements",
self.get_unlocked_achievements,
result_name="unlocked_achievements",
feature=Feature.ImportAchievements
)
self._register_method(
"import_local_games",
self.get_local_games,
result_name="local_games",
feature=Feature.ImportInstalledGames
)
self._register_notification("launch_game", self.launch_game, feature=Feature.LaunchGame)
self._register_notification("install_game", self.install_game, feature=Feature.InstallGame)
self._register_notification(
"uninstall_game",
self.uninstall_game,
feature=Feature.UninstallGame
)
self._register_method(
"import_friends",
self.get_friends,
result_name="friend_info_list",
feature=Feature.ImportFriends
)
self._register_method(
"import_user_infos",
self.get_users,
result_name="user_info_list",
feature=Feature.ImportUsers
)
self._register_method(
"send_message",
self.send_message,
feature=Feature.Chat
)
self._register_method(
"mark_as_read",
self.mark_as_read,
feature=Feature.Chat
)
self._register_method(
"import_rooms",
self.get_rooms,
result_name="rooms",
feature=Feature.Chat
)
self._register_method(
"import_room_history_from_message",
self.get_room_history_from_message,
result_name="messages",
feature=Feature.Chat
)
self._register_method(
"import_room_history_from_timestamp",
self.get_room_history_from_timestamp,
result_name="messages",
feature=Feature.Chat
)
self._register_method(
"import_game_times",
self.get_game_times,
result_name="game_times",
feature=Feature.ImportGameTime
)
@property
def features(self):
features = []
if self.__class__ != Plugin:
for feature, handlers in self._feature_methods.items():
if self._implements(handlers):
features.append(feature)
return features
def _implements(self, handlers):
for handler in handlers:
if handler.__name__ not in self.__class__.__dict__:
return False
return True
def _register_method(self, name, handler, result_name=None, internal=False, sensitive_params=False, feature=None):
if internal:
def method(*args, **kwargs):
result = handler(*args, **kwargs)
if result_name:
result = {
result_name: result
}
return result
self._server.register_method(name, method, True, sensitive_params)
else:
async def method(*args, **kwargs):
result = await handler(*args, **kwargs)
if result_name:
result = {
result_name: result
}
return result
self._server.register_method(name, method, False, sensitive_params)
if feature is not None:
self._feature_methods.setdefault(feature, []).append(handler)
def _register_notification(self, name, handler, internal=False, sensitive_params=False, feature=None):
self._server.register_notification(name, handler, internal, sensitive_params)
if feature is not None:
self._feature_methods.setdefault(feature, []).append(handler)
async def run(self):
"""Plugin main coorutine"""
async def pass_control():
while self._active:
try:
self.tick()
except Exception:
logging.exception("Unexpected exception raised in plugin tick")
await asyncio.sleep(1)
await asyncio.gather(pass_control(), self._server.run())
def _shutdown(self):
logging.info("Shuting down")
self._server.stop()
self._active = False
self.shutdown()
def _get_capabilities(self):
return {
"platform_name": self._platform,
"features": self.features,
"token": self._handshake_token
}
@staticmethod
def _ping():
pass
# notifications
def store_credentials(self, credentials):
"""Notify client to store plugin credentials.
They will be pass to next authencicate calls.
"""
self._notification_client.notify("store_credentials", credentials, sensitive_params=True)
def add_game(self, game):
params = {"owned_game" : game}
self._notification_client.notify("owned_game_added", params)
def remove_game(self, game_id):
params = {"game_id" : game_id}
self._notification_client.notify("owned_game_removed", params)
def update_game(self, game):
params = {"owned_game" : game}
self._notification_client.notify("owned_game_updated", params)
def unlock_achievement(self, game_id, achievement):
params = {
"game_id": game_id,
"achievement": achievement
}
self._notification_client.notify("achievement_unlocked", params)
def update_local_game_status(self, local_game):
params = {"local_game" : local_game}
self._notification_client.notify("local_game_status_changed", params)
def add_friend(self, user):
params = {"friend_info" : user}
self._notification_client.notify("friend_added", params)
def remove_friend(self, user_id):
params = {"user_id" : user_id}
self._notification_client.notify("friend_removed", params)
def update_room(self, room_id, unread_message_count=None, new_messages=None):
params = {"room_id": room_id}
if unread_message_count is not None:
params["unread_message_count"] = unread_message_count
if new_messages is not None:
params["messages"] = new_messages
self._notification_client.notify("chat_room_updated", params)
def update_game_time(self, game_time):
params = {"game_time" : game_time}
self._notification_client.notify("game_time_updated", params)
def lost_authentication(self):
self._notification_client.notify("authentication_lost", None)
# handlers
def tick(self):
"""This method is called periodicaly.
Override it to implement periodical tasks like refreshing cache.
This method should not be blocking - any longer actions should be
handled by asycio tasks.
"""
def shutdown(self):
"""This method is called on plugin shutdown.
Override it to implement tear down.
"""
# methods
async def authenticate(self, stored_credentials=None):
"""Overide this method to handle plugin authentication.
The method should return galaxy.api.types.Authentication
or raise galaxy.api.types.LoginError on authentication failure.
"""
raise NotImplementedError()
async def pass_login_credentials(self, step, credentials, cookies):
raise NotImplementedError()
async def get_owned_games(self):
raise NotImplementedError()
async def get_unlocked_achievements(self, game_id):
raise NotImplementedError()
async def get_local_games(self):
raise NotImplementedError()
async def launch_game(self, game_id):
raise NotImplementedError()
async def install_game(self, game_id):
raise NotImplementedError()
async def uninstall_game(self, game_id):
raise NotImplementedError()
async def get_friends(self):
raise NotImplementedError()
async def get_users(self, user_id_list):
raise NotImplementedError()
async def send_message(self, room_id, message_text):
raise NotImplementedError()
async def mark_as_read(self, room_id, last_message_id):
raise NotImplementedError()
async def get_rooms(self):
raise NotImplementedError()
async def get_room_history_from_message(self, room_id, message_id):
raise NotImplementedError()
async def get_room_history_from_timestamp(self, room_id, from_timestamp):
raise NotImplementedError()
async def get_game_times(self):
raise NotImplementedError()
def create_and_run_plugin(plugin_class, argv):
if len(argv) < 3:
logging.critical("Not enough parameters, required: token, port")
sys.exit(1)
token = argv[1]
try:
port = int(argv[2])
except ValueError:
logging.critical("Failed to parse port value: %s", argv[2])
sys.exit(2)
if not (1 <= port <= 65535):
logging.critical("Port value out of range (1, 65535)")
sys.exit(3)
if not issubclass(plugin_class, Plugin):
logging.critical("plugin_class must be subclass of Plugin")
sys.exit(4)
async def coroutine():
reader, writer = await asyncio.open_connection("127.0.0.1", port)
plugin = plugin_class(reader, writer, token)
await plugin.run()
try:
asyncio.run(coroutine())
except Exception:
logging.exception("Error while running plugin")
sys.exit(5)

94
src/galaxy/api/types.py Normal file
View File

@@ -0,0 +1,94 @@
from dataclasses import dataclass
from typing import List, Dict, Optional
from galaxy.api.consts import LicenseType, LocalGameState, PresenceState
@dataclass
class Authentication():
user_id: str
user_name: str
@dataclass
class Cookie():
name: str
value: str
domain: Optional[str] = None
path: Optional[str] = None
@dataclass
class NextStep():
next_step: str
auth_params: Dict[str, str]
cookies: Optional[List[Cookie]] = None
js: Optional[Dict[str, List[str]]] = None
@dataclass
class LicenseInfo():
license_type: LicenseType
owner: Optional[str] = None
@dataclass
class Dlc():
dlc_id: str
dlc_title: str
license_info: LicenseInfo
@dataclass
class Game():
game_id: str
game_title: str
dlcs: Optional[List[Dlc]]
license_info: LicenseInfo
@dataclass
class Achievement():
unlock_time: int
achievement_id: Optional[str] = None
achievement_name: Optional[str] = None
def __post_init__(self):
assert self.achievement_id or self.achievement_name, \
"One of achievement_id or achievement_name is required"
@dataclass
class LocalGame():
game_id: str
local_game_state: LocalGameState
@dataclass
class Presence():
presence_state: PresenceState
game_id: Optional[str] = None
presence_status: Optional[str] = None
@dataclass
class UserInfo():
user_id: str
is_friend: bool
user_name: str
avatar_url: str
presence: Presence
@dataclass
class FriendInfo():
user_id: str
user_name: str
@dataclass
class Room():
room_id: str
unread_message_count: int
last_message_id: str
@dataclass
class Message():
message_id: str
sender_id: str
sent_time: int
message_text: str
@dataclass
class GameTime():
game_id: str
time_played: int
last_played_time: int