예제 #1
0
class BNetPlugin(Plugin):
    PRODUCT_DB_PATH = pathlib.Path(AGENT_PATH) / 'product.db'
    CONFIG_PATH = CONFIG_PATH

    def __init__(self, reader, writer, token):
        super().__init__(Platform.Battlenet, version, reader, writer, token)

        log.info(f"Starting Battle.net plugin, version {version}")

        self.bnet_client = None
        self.local_client = LocalClient()
        self.authentication_client = AuthenticatedHttpClient(self)
        self.backend_client = BackendClient(self, self.authentication_client)
        self.social_features = SocialFeatures(self.authentication_client)
        self.error_state = False

        self.running_task = None

        self.database_parser = None
        self.config_parser = None
        self.uninstaller = None

        self.owned_games_cache = []

        self._classic_games_thread = None
        self._battlenet_games_thread = None
        self._installed_battlenet_games = {}
        self._installed_battlenet_games_lock = Lock()

        self.installed_games = self._parse_local_data()
        self.watched_running_games = set()

        self.notifications_enabled = False
        loop = asyncio.get_event_loop()
        loop.create_task(self._register_local_data_watcher())

    async def _register_local_data_watcher(self):
        async def ping(event, interval):
            while True:
                await asyncio.sleep(interval)
                if not self.watched_running_games:
                    if not event.is_set():
                        event.set()

        parse_local_data_event = asyncio.Event()
        FileWatcher(self.CONFIG_PATH, parse_local_data_event, interval=1)
        FileWatcher(self.PRODUCT_DB_PATH, parse_local_data_event, interval=2.5)
        asyncio.create_task(ping(parse_local_data_event, 30))
        while True:
            await parse_local_data_event.wait()
            refreshed_games = self._parse_local_data()
            if not self.notifications_enabled:
                self._update_statuses(refreshed_games, self.installed_games)
            self.installed_games = refreshed_games
            parse_local_data_event.clear()

    async def _notify_about_game_stop(self, game, starting_timeout):
        id_to_watch = game.info.id

        if id_to_watch in self.watched_running_games:
            log.debug(f'Game {id_to_watch} is already watched. Skipping')
            return

        try:
            self.watched_running_games.add(id_to_watch)
            await asyncio.sleep(starting_timeout)
            ProcessProvider().update_games_processes([game])
            log.info(f'Setuping process watcher for {game._processes}')
            loop = asyncio.get_event_loop()
            await loop.run_in_executor(None, game.wait_until_game_stops)
        finally:
            self.update_local_game_status(LocalGame(id_to_watch, LocalGameState.Installed))
            self.watched_running_games.remove(id_to_watch)

    def _update_statuses(self, refreshed_games, previous_games):
        for blizz_id, refr in refreshed_games.items():
            prev = previous_games.get(blizz_id, None)

            if prev is None:
                if refr.playable:
                    log.debug('Detected playable game')
                    state = LocalGameState.Installed
                else:
                    log.debug('Detected installation begin')
                    state = LocalGameState.None_
            elif refr.playable and not prev.playable:
                log.debug('Detected playable game')
                state = LocalGameState.Installed
            elif refr.last_played != prev.last_played:
                log.debug('Detected launched game')
                state = LocalGameState.Installed | LocalGameState.Running
                asyncio.create_task(self._notify_about_game_stop(refr, 5))
            else:
                continue

            log.info(f'Changing game {blizz_id} state to {state}')
            self.update_local_game_status(LocalGame(blizz_id, state))

        for blizz_id, prev in previous_games.items():
            refr = refreshed_games.get(blizz_id, None)
            if refr is None:
                log.debug('Detected uninstalled game')
                state = LocalGameState.None_
                self.update_local_game_status(LocalGame(blizz_id, state))

    def _load_local_files(self):
        try:
            product_db = load_product_db(self.PRODUCT_DB_PATH)
            self.database_parser = DatabaseParser(product_db)
        except FileNotFoundError as e:
            log.warning(f"product.db not found: {repr(e)}")
            return False
        except WindowsError as e:
            # 5 WindowsError access denied
            if e.winerror == 5:
                log.warning(f"product.db not accessible: {repr(e)}")
                self.config_parser = ConfigParser(None)
                return False
            else:
                raise ()
        except OSError as e:
            if e.errno == errno.EACCES:
                log.warning(f"product.db not accessible: {repr(e)}")
                self.config_parser = ConfigParser(None)
                return False
            else:
                raise ()
        else:
            if self.local_client.is_installed != self.database_parser.battlenet_present:
                self.local_client.refresh()

        try:
            config = load_config(self.CONFIG_PATH)
            self.config_parser = ConfigParser(config)
        except FileNotFoundError as e:
            log.warning(f"config file not found: {repr(e)}")
            self.config_parser = ConfigParser(None)
            return False
        except WindowsError as e:
            # 5 WindowsError access denied
            if e.winerror == 5:
                log.warning(f"config file not accessible: {repr(e)}")
                self.config_parser = ConfigParser(None)
                return False
            else:
                raise ()
        except OSError as e:
            if e.errno == errno.EACCES:
                log.warning(f"config file not accessible: {repr(e)}")
                self.config_parser = ConfigParser(None)
                return False
            else:
                raise ()
        return True

    def _get_battlenet_installed_games(self):

        def _add_battlenet_game(config_game, db_game):
            if config_game.uninstall_tag != db_game.uninstall_tag:
                return None
            try:
                blizzard_game = Blizzard[config_game.uid]
            except KeyError:
                log.warning(f'[{config_game.uid}] is not known blizzard game. Skipping')
                return None
            try:
                log.info(f"Adding {blizzard_game.blizzard_id} {blizzard_game.name} to installed games")
                return InstalledGame(
                    blizzard_game,
                    config_game.uninstall_tag,
                    db_game.version,
                    config_game.last_played,
                    db_game.install_path,
                    db_game.playable
                )
            except FileNotFoundError as e:
                log.warning(str(e) + '. Probably outdated product.db after uninstall. Skipping')
            return None

        games = {}
        for db_game in self.database_parser.games:
            for config_game in self.config_parser.games:
                installed_game = _add_battlenet_game(config_game, db_game)
                if installed_game:
                    games[installed_game.info.id] = installed_game
        self._installed_battlenet_games_lock.acquire()
        self._installed_battlenet_games = games
        self._installed_battlenet_games_lock.release()

    def _parse_local_data(self):
        """Game is considered as installed when present in both config and product.db"""
        games = {}
        # give threads 4 seconds to finish
        join_timeout = 4

        if not self._classic_games_thread or not self._classic_games_thread.isAlive():
            self._classic_games_thread = Thread(target=self.local_client.find_classic_games, daemon=True)
            self._classic_games_thread.start()
            log.info("Started classic games thread")

        if not self._load_local_files():
            self._classic_games_thread.join(join_timeout)
            if not self.local_client.classics_lock.acquire(False):
                return []
            else:
                installed_classics = self.local_client.installed_classics
                self.local_client.classics_lock.release()
                return installed_classics

        try:
            if SYSTEM == pf.WINDOWS and self.uninstaller is None:
                uninstaller_path = pathlib.Path(AGENT_PATH) / 'Blizzard Uninstaller.exe'
                self.uninstaller = Uninstaller(uninstaller_path)
        except FileNotFoundError as e:
            log.warning('uninstaller not found' + str(e))

        try:
            if self.local_client.is_installed != self.database_parser.battlenet_present:
                self.local_client.refresh()
            log.info(f"Games found in db {self.database_parser.games}")
            log.info(f"Games found in config {self.config_parser.games}")

            if not self._battlenet_games_thread or not self._battlenet_games_thread.isAlive():
                self._battlenet_games_thread = Thread(target=self._get_battlenet_installed_games, daemon=True)
                self._battlenet_games_thread.start()
                log.info("Started classic games thread")
        except Exception as e:
            log.exception(str(e))
        finally:
            self._classic_games_thread.join(join_timeout)
            self._battlenet_games_thread.join(join_timeout)

            if self.local_client.classics_lock.acquire(False):
                games = self.local_client.installed_classics
                self.local_client.classics_lock.release()

            if self._installed_battlenet_games_lock.acquire(False):
                games = {**self._installed_battlenet_games, **games}
                self._installed_battlenet_games_lock.release()

            return games

    def log_out(self):
        if self.backend_client:
            asyncio.create_task(self.authentication_client.shutdown())
        self.authentication_client.user_details = None
        self.owned_games_cache = []

    async def open_battlenet_browser(self):
        url = f"https://www.blizzard.com/apps/battle.net/desktop"
        log.info(f'Opening battle.net website: {url}')
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(None, lambda x: webbrowser.open(x, autoraise=True), url)

    async def install_game(self, game_id):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        installed_game = self.installed_games.get(game_id, None)
        if installed_game and os.access(installed_game.install_path, os.F_OK):
            log.warning("Received install command on an already installed game")
            return await self.launch_game(game_id)

        if game_id in Blizzard.legacy_game_ids:
            if SYSTEM == pf.WINDOWS:
                platform = 'windows'
            elif SYSTEM == pf.MACOS:
                platform = 'macos'
            webbrowser.open(f"https://www.blizzard.com/download/confirmation?platform={platform}&locale=enUS&version=LIVE&id={game_id}")
            return
        try:
            self.local_client.refresh()
            log.info(f'Installing game of id {game_id}')
            self.local_client.install_game(game_id)
        except ClientNotInstalledError as e:
            log.warning(e)
            await self.open_battlenet_browser()
        except Exception as e:
            log.exception(f"Installing game {game_id} failed: {e}")

    def _open_battlenet_at_id(self, game_id):
        try:
            self.local_client.refresh()
            self.local_client.open_battlenet(game_id)
        except Exception as e:
            log.exception(f"Opening battlenet client on specific game_id {game_id} failed {e}")
            try:
                self.local_client.open_battlenet()
            except Exception as e:
                log.exception(f"Opening battlenet client failed {e}")

    async def uninstall_game(self, game_id):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        if game_id == 'wow_classic':
            # attempting to uninstall classic wow through protocol gives you a message that the game cannot
            # be uninstalled through protocol and you should use battle.net
            return self._open_battlenet_at_id(game_id)

        if SYSTEM == pf.MACOS:
            self._open_battlenet_at_id(game_id)
        else:
            try:
                installed_game = self.installed_games.get(game_id, None)

                if installed_game is None or not os.access(installed_game.install_path, os.F_OK):
                    log.error(f'Cannot uninstall {Blizzard[game_id].uid}')
                    self.update_local_game_status(LocalGame(game_id, LocalGameState.None_))
                    return

                if not isinstance(installed_game.info, ClassicGame):
                    if self.uninstaller is None:
                        raise FileNotFoundError('Uninstaller not found')

                uninstall_tag = installed_game.uninstall_tag
                client_lang = self.config_parser.locale_language
                self.uninstaller.uninstall_game(installed_game, uninstall_tag, client_lang)

            except Exception as e:
                log.exception(f'Uninstalling game {game_id} failed: {e}')

    async def launch_game(self, game_id):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        try:
            if self.installed_games is None:
                log.error(f'Launching game that is not installed: {game_id}')
                return await self.install_game(game_id)

            game = self.installed_games.get(game_id, None)
            if game is None:
                log.error(f'Launching game that is not installed: {game_id}')
                return await self.install_game(game_id)

            if isinstance(game.info, ClassicGame):
                log.info(f'Launching game of id: {game_id}, {game} at path {os.path.join(game.install_path, game.info.exe)}')
                if SYSTEM == pf.WINDOWS:
                    subprocess.Popen(os.path.join(game.install_path, game.info.exe))
                elif SYSTEM == pf.MACOS:
                    if not game.info.bundle_id:
                        log.warning(f"{game.name} has no bundle id, help by providing us bundle id of this game")
                    subprocess.Popen(['open', '-b', game.info.bundle_id])

                self.update_local_game_status(LocalGame(game_id, LocalGameState.Installed | LocalGameState.Running))
                asyncio.create_task(self._notify_about_game_stop(game, 6))
                return

            self.local_client.refresh()
            log.info(f'Launching game of id: {game_id}, {game}')
            await self.local_client.launch_game(game, wait_sec=60)

            self.update_local_game_status(LocalGame(game_id, LocalGameState.Installed | LocalGameState.Running))
            self.local_client.close_window()
            asyncio.create_task(self._notify_about_game_stop(game, 3))

        except ClientNotInstalledError as e:
            log.warning(e)
            await self.open_battlenet_browser()
        except TimeoutError as e:
            log.warning(str(e))
        except Exception as e:
            log.exception(f"Launching game {game_id} failed: {e}")

    async def authenticate(self, stored_credentials=None):
        try:
            if stored_credentials:
                auth_data = self.authentication_client.process_stored_credentials(stored_credentials)
                try:
                    await self.authentication_client.create_session()
                    await self.backend_client.refresh_cookies()
                    auth_status = await self.backend_client.validate_access_token(auth_data.access_token)
                except (BackendNotAvailable, BackendError, NetworkError, UnknownError, BackendTimeout) as e:
                    raise e
                except Exception:
                    raise InvalidCredentials()
                if self.authentication_client.validate_auth_status(auth_status):
                    self.authentication_client.user_details = await self.backend_client.get_user_info()
                return self.authentication_client.parse_user_details()
            else:
                return self.authentication_client.authenticate_using_login()
        except Exception as e:
            raise e

    async def pass_login_credentials(self, step, credentials, cookies):
        if "logout&app=oauth" in credentials['end_uri']:
            # 2fa expired, repeat authentication
            return self.authentication_client.authenticate_using_login()

        if self.authentication_client.attempted_to_set_battle_tag:
            self.authentication_client.user_details = await self.backend_client.get_user_info()
            return self.authentication_client.parse_auth_after_setting_battletag()

        cookie_jar = self.authentication_client.parse_cookies(cookies)
        auth_data = await self.authentication_client.get_auth_data_login(cookie_jar, credentials)

        try:
            await self.authentication_client.create_session()
            await self.backend_client.refresh_cookies()
        except (BackendNotAvailable, BackendError, NetworkError, UnknownError, BackendTimeout) as e:
            raise e
        except Exception:
            raise InvalidCredentials()

        auth_status = await self.backend_client.validate_access_token(auth_data.access_token)
        if not ("authorities" in auth_status and "IS_AUTHENTICATED_FULLY" in auth_status["authorities"]):
            raise InvalidCredentials()

        self.authentication_client.user_details = await self.backend_client.get_user_info()

        self.authentication_client.set_credentials()

        return self.authentication_client.parse_battletag()

    async def get_friends(self):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()
        friends_list = await self.social_features.get_friends()
        return [FriendInfo(user_id=friend.id.low, user_name='') for friend in friends_list]

    async def get_owned_games(self):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        def _parse_classic_games(classic_games):
            for classic_game in classic_games["classicGames"]:
                log.info(f"looking for {classic_game} in classic games")
                try:
                    blizzard_game = Blizzard[classic_game["localizedGameName"].replace(u'\xa0', ' ')]
                    log.info(f"match! {blizzard_game}")
                    classic_game["titleId"] = blizzard_game.uid
                    classic_game["gameAccountStatus"] = "Good"
                except KeyError:
                    continue

            return classic_games

        def _get_not_added_free_games(owned_games):
            owned_games_ids = []
            for game in owned_games:
                if "titleId" in game:
                    owned_games_ids.append(str(game["titleId"]))

            return [{"titleId": game.blizzard_id,
                    "localizedGameName": game.name,
                    "gameAccountStatus": "Free"}
                    for game in Blizzard.free_games if game.blizzard_id not in owned_games_ids]

        try:
            games = await self.backend_client.get_owned_games()
            classic_games = _parse_classic_games(await self.backend_client.get_owned_classic_games())
            owned_games = games["gameAccounts"] + classic_games["classicGames"]

            # Add wow classic if retail wow is present in owned games
            for owned_game in owned_games.copy():
                if 'titleId' in owned_game:
                    if owned_game['titleId'] == 5730135:
                        owned_games.append({'titleId': 'wow_classic',
                                            'localizedGameName': 'World of Warcraft Classic',
                                            'gameAccountStatus': owned_game['gameAccountStatus']})

            free_games_to_add = _get_not_added_free_games(owned_games)
            owned_games += free_games_to_add
            log.info(f"Owned games {owned_games} with free games")
            self.owned_games_cache = owned_games
            return [
                Game(
                    str(game["titleId"]),
                    game["localizedGameName"],
                    [],
                    LicenseInfo(License_Map[game["gameAccountStatus"]]),
                )
                for game in self.owned_games_cache if "titleId" in game
            ]
        except Exception as e:
            log.exception(f"failed to get owned games: {repr(e)}")
            raise

    async def get_local_games(self):

        try:
            local_games = []
            running_games = ProcessProvider().update_games_processes(self.installed_games.values())
            log.info(f"Installed games {self.installed_games.items()}")
            log.info(f"Running games {running_games}")
            for id_, game in self.installed_games.items():
                if game.playable:
                    state = LocalGameState.Installed
                    if id_ in running_games:
                        state |= LocalGameState.Running
                else:
                    state = LocalGameState.None_
                local_games.append(LocalGame(id_, state))

            return local_games

        except Exception as e:
            log.exception(f"failed to get local games: {str(e)}")
            raise

        finally:
            self.enable_notifications = True

    async def _get_wow_achievements(self):
        achievements = []
        try:
            characters_data = await self.backend_client.get_wow_character_data()
            characters_data = characters_data["characters"]

            wow_character_data = await asyncio.gather(
                *[
                    self.backend_client.get_wow_character_achievements(character["realm"], character["name"])
                    for character in characters_data
                ],
                return_exceptions=True,
            )

            for data in wow_character_data:
                if isinstance(data, requests.Timeout) or isinstance(data, requests.ConnectionError):
                    raise data

            wow_achievement_data = [
                list(
                    zip(
                        data["achievements"]["achievementsCompleted"],
                        data["achievements"]["achievementsCompletedTimestamp"],
                    )
                )
                for data in wow_character_data
                if type(data) is dict
            ]

            already_in = set()

            for char_ach in wow_achievement_data:
                for ach in char_ach:
                    if ach[0] not in already_in:
                        achievements.append(Achievement(achievement_id=ach[0], unlock_time=int(ach[1] / 1000)))
                        already_in.add(ach[0])
        except (AccessTokenExpired, BackendError) as e:
            log.exception(str(e))
        with open('wow.json', 'w') as f:
            f.write(json.dumps(achievements, cls=DataclassJSONEncoder))
        return achievements

    async def _get_sc2_achievements(self):
        account_data = await self.backend_client.get_sc2_player_data(self.authentication_client.user_details["id"])

        # TODO what if more sc2 accounts?
        assert len(account_data) == 1
        account_data = account_data[0]

        profile_data = await self.backend_client.get_sc2_profile_data(
                                                         account_data["regionId"], account_data["realmId"],
                                                         account_data["profileId"]
                                                         )

        sc2_achievement_data = [
            Achievement(achievement_id=achievement["achievementId"], unlock_time=achievement["completionDate"])
            for achievement in profile_data["earnedAchievements"]
            if achievement["isComplete"]
        ]

        with open('sc2.json', 'w') as f:
            f.write(json.dumps(sc2_achievement_data, cls=DataclassJSONEncoder))
        return sc2_achievement_data

    # async def get_unlocked_achievements(self, game_id):
    #     if not self.website_client.is_authenticated():
    #         raise AuthenticationRequired()
    #     try:
    #         if game_id == "21298":
    #             return await self._get_sc2_achievements()
    #         elif game_id == "5730135":
    #             return await self._get_wow_achievements()
    #         else:
    #             return []
    #     except requests.Timeout:
    #         raise BackendTimeout()
    #     except requests.ConnectionError:
    #         raise NetworkError()
    #     except Exception as e:
    #         log.exception(str(e))
    #         return []

    async def _tick_runner(self):
        if not self.bnet_client:
            return
        try:
            self.error_state = await self.bnet_client.tick()
        except Exception as e:
            self.error_state = True
            log.exception(f"error state: {str(e)}")
            raise

    def tick(self):
        if not self.error_state and (not self.running_task or self.running_task.done()):
            self.running_task = asyncio.create_task(self._tick_runner())
        elif self.error_state:
            sys.exit(1)

    def shutdown(self):
        log.info("Plugin shutdown.")
        asyncio.create_task(self.authentication_client.shutdown())
예제 #2
0
class RockstarPlugin(Plugin):
    def __init__(self, reader, writer, token):
        super().__init__(Platform.RiotGames, __version__, reader, writer,
                         token)
        self.games_cache = games_cache
        self._http_client = AuthenticatedHttpClient(self.store_credentials)
        self._local_client = LocalClient()
        self.total_games_cache = self.create_total_games_cache()
        self.friends_cache = []
        self.owned_games_cache = []
        self.local_games_cache = {}
        self.running_games_pids = {}
        self.game_is_loading = True
        self.checking_for_new_games = False
        self.updating_game_statuses = False
        self.buffer = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH)
        ctypes.windll.shell32.SHGetFolderPathW(None, 5, None, 0, self.buffer)
        self.documents_location = self.buffer.value

    def is_authenticated(self):
        return self._http_client.is_authenticated()

    async def authenticate(self, stored_credentials=None):
        self._http_client.create_session(stored_credentials)
        if not stored_credentials:
            return NextStep("web_session", AUTH_PARAMS)
        try:
            log.info("INFO: The credentials were successfully obtained.")
            cookies = pickle.loads(
                bytes.fromhex(stored_credentials['session_object'])).cookies
            log.debug("ROCKSTAR_COOKIES_FROM_HEX: " + str(cookies))
            for cookie in cookies:
                cookie_object = {
                    "name": cookie.name,
                    "value": cookie.value,
                    "domain": cookie.domain,
                    "path": cookie.path
                }
                self._http_client.update_cookie(cookie_object)
            self._http_client.set_current_auth_token(
                stored_credentials['current_auth_token'])
            log.info(
                "INFO: The stored credentials were successfully parsed. Beginning authentication..."
            )
            user = await self._http_client.authenticate()
            return Authentication(user_id=user['rockstar_id'],
                                  user_name=user['display_name'])
        except Exception as e:
            log.warning(
                "ROCKSTAR_AUTH_WARNING: The exception " + repr(e) +
                " was thrown, presumably because of "
                "outdated credentials. Attempting to get new credentials...")
            self._http_client.set_auth_lost_callback(self.lost_authentication)
            try:
                user = await self._http_client.authenticate()
                return Authentication(user_id=user['rockstar_id'],
                                      user_name=user['display_name'])
            except Exception as e:
                log.error(
                    "ROCKSTAR_AUTH_FAILURE: Something went terribly wrong with the re-authentication. "
                    + repr(e))
                log.exception("ROCKSTAR_STACK_TRACE")
                raise InvalidCredentials()

    async def pass_login_credentials(self, step, credentials, cookies):
        log.debug("ROCKSTAR_COOKIE_LIST: " + str(cookies))
        for cookie in cookies:
            if cookie['name'] == "ScAuthTokenData":
                self._http_client.set_current_auth_token(cookie['value'])
            cookie_object = {
                "name": cookie['name'],
                "value": cookie['value'],
                "domain": cookie['domain'],
                "path": cookie['path']
            }
            self._http_client.update_cookie(cookie_object)
        try:
            user = await self._http_client.authenticate()
        except Exception as e:
            log.error(repr(e))
            raise InvalidCredentials()
        return Authentication(user_id=user["rockstar_id"],
                              user_name=user["display_name"])

    async def shutdown(self):
        await self._http_client.close()

    def create_total_games_cache(self):
        cache = []
        for title_id in list(games_cache):
            cache.append(self.create_game_from_title_id(title_id))
        return cache

    async def get_friends(self):
        # This method is currently causing the plugin to crash after its second call. I am unsure of why this is
        # happening, but it needs to be fixed.

        # NOTE: This will return a list of type FriendInfo.
        # The Social Club website returns a list of the current user's friends through the url
        # https://scapi.rockstargames.com/friends/getFriendsFiltered?onlineService=sc&nickname=&pageIndex=0&pageSize=30.
        # The nickname URL parameter is left blank because the website instead uses the bearer token to get the correct
        # information. The last two parameters are of great importance, however. The parameter pageSize determines the
        # number of friends given on that page's list, while pageIndex keeps track of the page that the information is
        # on. The maximum number for pageSize is 30, so that is what we will use to cut down the number of HTTP
        # requests.

        # We first need to get the number of friends.
        url = (
            "https://scapi.rockstargames.com/friends/getFriendsFiltered?onlineService=sc&nickname=&"
            "pageIndex=0&pageSize=30")
        current_page = await self._http_client.get_json_from_request_strict(url
                                                                            )
        log.debug("ROCKSTAR_FRIENDS_REQUEST: " + str(current_page))
        num_friends = current_page['rockstarAccountList']['totalFriends']
        num_pages_required = num_friends / 30 if num_friends % 30 != 0 else (
            num_friends / 30) - 1

        # Now, we need to get the information about the friends.
        friends_list = current_page['rockstarAccountList']['rockstarAccounts']
        return_list = []
        for i in range(0, len(friends_list)):
            friend = FriendInfo(friends_list[i]['rockstarId'],
                                friends_list[i]['displayName'])
            return_list.append(FriendInfo)
            for cached_friend in self.friends_cache:
                if cached_friend.user_id == friend.user_id:
                    break
            else:
                self.friends_cache.append(friend)
                self.add_friend(friend)
            log.debug("ROCKSTAR_FRIEND: Found " + friend.user_name +
                      " (Rockstar ID: " + str(friend.user_id) + ")")

        # The first page is finished, but now we need to work on any remaining pages.
        if num_pages_required > 0:
            for i in range(1, int(num_pages_required + 1)):
                url = (
                    "https://scapi.rockstargames.com/friends/getFriendsFiltered?onlineService=sc&nickname=&"
                    "pageIndex=" + str(i) + "&pageSize=30")
                return_list.append(friend
                                   for friend in await self._get_friends(url))
        return return_list

    async def _get_friends(self, url):
        current_page = self._http_client.get_json_from_request_strict(url)
        friends_list = current_page['rockstarAccountList']['rockstarAccounts']
        return_list = []
        for i in range(0, len(friends_list)):
            friend = FriendInfo(friends_list[i]['rockstarId'],
                                friends_list[i]['displayName'])
            return_list.append(FriendInfo)
            for cached_friend in self.friends_cache:
                if cached_friend.user_id == friend.user_id:
                    break
            else:  # An else-statement occurs after a for-statement if the latter finishes WITHOUT breaking.
                self.friends_cache.append(friend)
                self.add_friend(friend)
            log.debug("ROCKSTAR_FRIEND: Found " + friend.user_name +
                      " (Rockstar ID: " + str(friend.user_id) + ")")
        return return_list

    async def get_owned_games(self):
        # Here is the actual implementation of getting the user's owned games:
        # -Get the list of games_played from rockstargames.com/auth/get-user.json.
        #   -If possible, use the launcher log to confirm which games are actual launcher games and which are
        #   Steam/Retail games.
        #   -If it is not possible to use the launcher log, then just use the list provided by the website.
        if not self.is_authenticated():
            raise AuthenticationRequired()

        # Get the list of games_played from https://www.rockstargames.com/auth/get-user.json.
        owned_title_ids = []
        online_check_success = True
        try:
            played_games = await self._http_client.get_played_games()
            for game in played_games:
                title_id = get_game_title_id_from_online_title_id(game)
                owned_title_ids.append(title_id)
                log.debug("ROCKSTAR_ONLINE_GAME: Found played game " +
                          title_id + "!")
        except Exception as e:
            log.error(
                "ROCKSTAR_PLAYED_GAMES_ERROR: The exception " + repr(e) +
                " was thrown when attempting to get the"
                " user's played games online. Falling back to log file check..."
            )
            online_check_success = False

        # The log is in the Documents folder.
        current_log_count = 0
        log_file = None
        log_file_append = ""
        # The Rockstar Games Launcher generates 10 log files before deleting them in a FIFO fashion. Old log files are
        # given a number ranging from 1 to 9 in their name. In case the first log file does not have all of the games,
        # we need to check the other log files, if possible.
        while current_log_count < 10:
            try:
                if current_log_count != 0:
                    log_file_append = ".0" + str(current_log_count)
                log_file = os.path.join(
                    self.documents_location,
                    "Rockstar Games\\Launcher\\launcher" + log_file_append +
                    ".log")
                log.debug("ROCKSTAR_LOG_LOCATION: Checking the file " +
                          log_file + "...")
                owned_title_ids = await self.parse_log_file(
                    log_file, owned_title_ids, online_check_success)
                break
            except NoGamesInLogException:
                log.warning(
                    "ROCKSTAR_LOG_WARNING: There are no owned games listed in "
                    + str(log_file) + ". Moving to "
                    "the next log file...")
                current_log_count += 1
            except NoLogFoundException:
                log.warning(
                    "ROCKSTAR_LAST_LOG_REACHED: There are no more log files that can be found and/or read "
                    "from. Assuming that the online list is correct...")
                break
            except Exception:
                # This occurs after ROCKSTAR_LOG_ERROR.
                break
        if current_log_count == 10:
            log.warning(
                "ROCKSTAR_LAST_LOG_REACHED: There are no more log files that can be found and/or read "
                "from. Assuming that the online list is correct...")
        remove_all = False
        if len(self.owned_games_cache) == 0:
            remove_all = True
        for title_id in owned_title_ids:
            game = self.create_game_from_title_id(title_id)
            if game not in self.owned_games_cache:
                log.debug("ROCKSTAR_ADD_GAME: Adding " + title_id +
                          " to owned games cache...")
                self.add_game(game)
                self.owned_games_cache.append(game)
        if remove_all is True:
            for key, value in games_cache.items():
                if key not in owned_title_ids:
                    log.debug("ROCKSTAR_REMOVE_GAME: Removing " + key +
                              " from owned games cache...")
                    self.remove_game(value['rosTitleId'])
        else:
            for game in self.owned_games_cache:
                if get_game_title_id_from_ros_title_id(
                        game.game_id) not in owned_title_ids:
                    log.debug(
                        "ROCKSTAR_REMOVE_GAME: Removing " +
                        get_game_title_id_from_ros_title_id(game.game_id) +
                        " from owned games cache...")
                    self.remove_game(game.game_id)
                    self.owned_games_cache.remove(game)
        return self.owned_games_cache

    @staticmethod
    async def parse_log_file(log_file, owned_title_ids, online_check_success):
        owned_title_ids_ = owned_title_ids
        checked_games_count = 0
        total_games_count = len(games_cache)
        if os.path.exists(log_file):
            with FileReadBackwards(log_file, encoding="utf-8") as frb:
                while checked_games_count < total_games_count:
                    try:
                        line = frb.readline()
                    except UnicodeDecodeError:
                        log.warning(
                            "ROCKSTAR_LOG_UNICODE_WARNING: An invalid Unicode character was found in the line "
                            + line + ". Continuing to next line...")
                        continue
                    except Exception as e:
                        log.error(
                            "ROCKSTAR_LOG_ERROR: Reading " + line +
                            " from the log file resulted in the "
                            "exception " + repr(e) +
                            " being thrown. Using the online list... (Please report "
                            "this issue on the plugin's GitHub page!)")
                        raise
                    if not line:
                        log.error(
                            "ROCKSTAR_LOG_FINISHED_ERROR: The entire log file was read, but all of the games "
                            "could not be accounted for. Proceeding to import the games that have been "
                            "confirmed...")
                        raise NoGamesInLogException()
                    # We need to do two main things with the log file:
                    # 1. If a game is present in owned_title_ids but not owned according to the log file, then it is
                    #    assumed to be a non-Launcher game, and is removed from the list.
                    # 2. If a game is owned according to the log file but is not already present in owned_title_ids,
                    #    then it is assumed that the user has purchased the game on the Launcher, but has not yet played
                    #    it. In this case, the game will be added to owned_title_ids.
                    if ("launcher" not in line) and ("on branch "
                                                     in line):  # Found a game!
                        # Each log line for a title branch report describes the title id of the game starting at
                        # character 65. Interestingly, the lines all have the same colon as character 75. This implies
                        # that this format was intentionally done by Rockstar, so they likely will not change it anytime
                        # soon.
                        title_id = line[65:75].strip()
                        log.debug(
                            "ROCKSTAR_LOG_GAME: The game with title ID " +
                            title_id + " is owned!")
                        if title_id not in owned_title_ids_:
                            if online_check_success is True:
                                # Case 2: The game is owned, but has not been played.
                                log.warning(
                                    "ROCKSTAR_UNPLAYED_GAME: The game with title ID "
                                    + title_id +
                                    " is owned, but it has never been played!")
                            owned_title_ids_.append(title_id)
                        checked_games_count += 1
                    elif "no branches!" in line:
                        title_id = line[65:75].strip()
                        if title_id in owned_title_ids_:
                            # Case 1: The game is not actually owned on the launcher.
                            log.warning(
                                "ROCKSTAR_FAKE_GAME: The game with title ID " +
                                title_id + " is not owned on "
                                "the Rockstar Games Launcher!")
                            owned_title_ids_.remove(title_id)
                        checked_games_count += 1
                    if checked_games_count == total_games_count:
                        break
            return owned_title_ids_
        else:
            raise NoLogFoundException()

    async def get_local_games(self):
        # Since the API requires that get_local_games returns a list of LocalGame objects, local_list is the value that
        # needs to be returned. However, for internal use (the self.local_games_cache field), the dictionary local_games
        # is used for greater flexibility.
        local_games = {}
        local_list = []
        for game in self.total_games_cache:
            title_id = get_game_title_id_from_ros_title_id(str(game.game_id))
            check = self._local_client.get_path_to_game(title_id)
            if check is not None:
                if (title_id in self.running_games_pids
                        and check_if_process_exists(
                            self.running_games_pids[title_id][0])):
                    local_game = self.create_local_game_from_title_id(
                        title_id, True, True)
                else:
                    local_game = self.create_local_game_from_title_id(
                        title_id, False, True)
            else:
                local_game = self.create_local_game_from_title_id(
                    title_id, False, False)
            local_games[title_id] = local_game
            local_list.append(local_game)
        self.local_games_cache = local_games
        log.debug("ROCKSTAR_INSTALLED_GAMES: " + str(local_games))
        return local_list

    async def check_for_new_games(self):
        self.checking_for_new_games = True
        await self.get_owned_games()
        await asyncio.sleep(60)
        self.checking_for_new_games = False

    async def check_game_statuses(self):
        self.updating_game_statuses = True
        old_local_game_cache = self.local_games_cache
        await self.get_local_games()
        for title_id, local_game in self.local_games_cache.items():
            if (local_game.local_game_state !=
                (old_local_game_cache[title_id]).local_game_state):
                log.debug("ROCKSTAR_LOCAL_CHANGE: The status for " + title_id +
                          " has changed.")
                self.update_local_game_status(local_game)
        #   else:
        #       log.debug("ROCKSTAR_LOCAL_REMAIN: The status for " + title_id + " has not changed.") - Reduce Console
        #       Spam (Enable this if you need to.)
        await asyncio.sleep(5)
        self.updating_game_statuses = False

    async def launch_game(self, game_id):
        title_id = get_game_title_id_from_ros_title_id(game_id)
        self.running_games_pids[title_id] = [
            await self._local_client.launch_game_from_title_id(title_id), True
        ]
        log.debug("ROCKSTAR_PIDS: " + str(self.running_games_pids))
        if self.running_games_pids[title_id][0] != '-1':
            self.update_local_game_status(
                LocalGame(game_id,
                          LocalGameState.Running | LocalGameState.Installed))

    async def install_game(self, game_id):
        title_id = get_game_title_id_from_ros_title_id(game_id)
        log.debug("ROCKSTAR_INSTALL_REQUEST: Requesting to install " +
                  title_id + "...")
        self._local_client.install_game_from_title_id(title_id)
        # If the game is not released yet, then we should allow them to see this on the Rockstar Games Launcher, but the
        # game's installation status should not be changed.
        if games_cache[title_id]["isPreOrder"]:
            return
        self.update_local_game_status(
            LocalGame(game_id, LocalGameState.Installed))

    async def uninstall_game(self, game_id):
        title_id = get_game_title_id_from_ros_title_id(game_id)
        log.debug("ROCKSTAR_UNINSTALL_REQUEST: Requesting to uninstall " +
                  title_id + "...")
        self._local_client.uninstall_game_from_title_id(title_id)
        self.update_local_game_status(LocalGame(game_id, LocalGameState.None_))

    def create_game_from_title_id(self, title_id):
        return Game(self.games_cache[title_id]["rosTitleId"],
                    self.games_cache[title_id]["friendlyName"], None,
                    self.games_cache[title_id]["licenseInfo"])

    def create_local_game_from_title_id(self, title_id, is_running,
                                        is_installed):
        if is_running:
            return LocalGame(self.games_cache[title_id]["rosTitleId"],
                             LocalGameState.Running | LocalGameState.Installed)
        elif is_installed:
            return LocalGame(self.games_cache[title_id]["rosTitleId"],
                             LocalGameState.Installed)
        else:
            return LocalGame(self.games_cache[title_id]["rosTitleId"],
                             LocalGameState.None_)

    def tick(self):
        if not self.is_authenticated():
            return
        if not self.checking_for_new_games:
            log.debug("Checking for new games...")
            asyncio.create_task(self.check_for_new_games())
        if not self.updating_game_statuses:
            log.debug("Checking local game statuses...")
            asyncio.create_task(self.check_game_statuses())
예제 #3
0
class BNetPlugin(Plugin):
    def __init__(self, reader, writer, token):
        super().__init__(Platform.Battlenet, version, reader, writer, token)
        self.local_client = LocalClient(self._update_statuses)
        self.authentication_client = AuthenticatedHttpClient(self)
        self.backend_client = BackendClient(self, self.authentication_client)

        self.watched_running_games = set()

    def handshake_complete(self):
        self.create_task(self.__delayed_handshake(), 'delayed handshake')

    async def __delayed_handshake(self):
        """
        Adds some minimal delay on Galaxy start before registering local data watchers.
        Apparently Galaxy may be not ready to receive notifications even after handshake_complete.
        """
        await asyncio.sleep(1)
        self.create_task(self.local_client.register_local_data_watcher(),
                         'local data watcher')
        self.create_task(self.local_client.register_classic_games_updater(),
                         'classic games updater')

    async def _notify_about_game_stop(self, game, starting_timeout):
        id_to_watch = game.info.uid

        if id_to_watch in self.watched_running_games:
            log.debug(f'Game {id_to_watch} is already watched. Skipping')
            return

        try:
            self.watched_running_games.add(id_to_watch)
            await asyncio.sleep(starting_timeout)
            ProcessProvider().update_games_processes([game])
            log.info(f'Setuping process watcher for {game._processes}')
            loop = asyncio.get_event_loop()
            await loop.run_in_executor(None, game.wait_until_game_stops)
        finally:
            self.update_local_game_status(
                LocalGame(id_to_watch, LocalGameState.Installed))
            self.watched_running_games.remove(id_to_watch)

    def _update_statuses(self, refreshed_games, previous_games):
        for blizz_id, refr in refreshed_games.items():
            prev = previous_games.get(blizz_id, None)

            if prev is None:
                if refr.has_galaxy_installed_state:
                    log.debug('Detected playable game')
                    state = LocalGameState.Installed
                else:
                    log.debug('Detected not-fully installed game')
                    continue
            elif refr.has_galaxy_installed_state and not prev.has_galaxy_installed_state:
                log.debug('Detected playable game')
                state = LocalGameState.Installed
            elif refr.last_played != prev.last_played:
                log.debug('Detected launched game')
                state = LocalGameState.Installed | LocalGameState.Running
                self.create_task(self._notify_about_game_stop(refr, 5),
                                 'game stop waiter')
            else:
                continue

            log.info(f'Changing game {blizz_id} state to {state}')
            self.update_local_game_status(LocalGame(blizz_id, state))

        for blizz_id, prev in previous_games.items():
            refr = refreshed_games.get(blizz_id, None)
            if refr is None:
                log.debug('Detected uninstalled game')
                state = LocalGameState.None_
                self.update_local_game_status(LocalGame(blizz_id, state))

    def log_out(self):
        if self.backend_client:
            asyncio.create_task(self.authentication_client.shutdown())
        self.authentication_client.user_details = None

    async def open_battlenet_browser(self):
        url = self.authentication_client.blizzard_battlenet_download_url
        log.info(f'Opening battle.net website: {url}')
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(
            None, lambda x: webbrowser.open(x, autoraise=True), url)

    async def install_game(self, game_id):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        installed_game = self.local_client.get_installed_games().get(
            game_id, None)
        if installed_game and os.access(installed_game.install_path, os.F_OK):
            log.warning(
                "Received install command on an already installed game")
            return await self.launch_game(game_id)

        if game_id in [classic.uid for classic in Blizzard.CLASSIC_GAMES]:
            if SYSTEM == pf.WINDOWS:
                platform = 'windows'
            elif SYSTEM == pf.MACOS:
                platform = 'macos'
            webbrowser.open(
                f"https://www.blizzard.com/download/confirmation?platform={platform}&locale=enUS&version=LIVE&id={game_id}"
            )
            return
        try:
            self.local_client.refresh()
            log.info(f'Installing game of id {game_id}')
            self.local_client.install_game(game_id)
        except ClientNotInstalledError as e:
            log.warning(e)
            await self.open_battlenet_browser()
        except Exception as e:
            log.exception(f"Installing game {game_id} failed: {e}")

    def _open_battlenet_at_id(self, game_id):
        try:
            self.local_client.refresh()
            self.local_client.open_battlenet(game_id)
        except Exception as e:
            log.exception(
                f"Opening battlenet client on specific game_id {game_id} failed {e}"
            )
            try:
                self.local_client.open_battlenet()
            except Exception as e:
                log.exception(f"Opening battlenet client failed {e}")

    async def uninstall_game(self, game_id):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        if game_id == 'wow_classic':
            # attempting to uninstall classic wow through protocol gives you a message that the game cannot
            # be uninstalled through protocol and you should use battle.net
            return self._open_battlenet_at_id(game_id)

        if SYSTEM == pf.MACOS:
            self._open_battlenet_at_id(game_id)
        else:
            try:
                installed_game = self.local_client.get_installed_games().get(
                    game_id, None)

                if installed_game is None or not os.access(
                        installed_game.install_path, os.F_OK):
                    log.error(f'Cannot uninstall {game_id}')
                    self.update_local_game_status(
                        LocalGame(game_id, LocalGameState.None_))
                    return

                if not isinstance(installed_game.info, ClassicGame):
                    if self.local_client.uninstaller is None:
                        raise FileNotFoundError('Uninstaller not found')

                uninstall_tag = installed_game.uninstall_tag
                client_lang = self.local_client.config_parser.locale_language
                self.local_client.uninstaller.uninstall_game(
                    installed_game, uninstall_tag, client_lang)

            except Exception as e:
                log.exception(f'Uninstalling game {game_id} failed: {e}')

    async def launch_game(self, game_id):
        try:
            game = self.local_client.get_installed_games().get(game_id, None)
            if game is None:
                log.error(f'Launching game that is not installed: {game_id}')
                return await self.install_game(game_id)

            if isinstance(game.info, ClassicGame):
                log.info(
                    f'Launching game of id: {game_id}, {game} at path {os.path.join(game.install_path, game.info.exe)}'
                )
                if SYSTEM == pf.WINDOWS:
                    subprocess.Popen(
                        os.path.join(game.install_path, game.info.exe))
                elif SYSTEM == pf.MACOS:
                    if not game.info.bundle_id:
                        log.warning(
                            f"{game.name} has no bundle id, help by providing us bundle id of this game"
                        )
                    subprocess.Popen(['open', '-b', game.info.bundle_id])

                self.update_local_game_status(
                    LocalGame(
                        game_id,
                        LocalGameState.Installed | LocalGameState.Running))
                asyncio.create_task(self._notify_about_game_stop(game, 6))
                return

            self.local_client.refresh()
            log.info(f'Launching game of id: {game_id}, {game}')
            await self.local_client.launch_game(game, wait_sec=60)

            self.update_local_game_status(
                LocalGame(game_id,
                          LocalGameState.Installed | LocalGameState.Running))
            self.local_client.close_window()
            asyncio.create_task(self._notify_about_game_stop(game, 3))

        except ClientNotInstalledError as e:
            log.warning(e)
            await self.open_battlenet_browser()
        except TimeoutError as e:
            log.warning(str(e))
        except Exception as e:
            log.exception(f"Launching game {game_id} failed: {e}")

    async def authenticate(self, stored_credentials=None):
        try:
            if stored_credentials:
                auth_data = self.authentication_client.process_stored_credentials(
                    stored_credentials)
                try:
                    await self.authentication_client.create_session()
                    await self.backend_client.refresh_cookies()
                    auth_status = await self.backend_client.validate_access_token(
                        auth_data.access_token)
                except (BackendNotAvailable, BackendError, NetworkError,
                        UnknownError, BackendTimeout) as e:
                    raise e
                except Exception:
                    raise InvalidCredentials()
                if self.authentication_client.validate_auth_status(
                        auth_status):
                    self.authentication_client.user_details = await self.backend_client.get_user_info(
                    )
                return self.authentication_client.parse_user_details()
            else:
                return self.authentication_client.authenticate_using_login()
        except Exception as e:
            raise e

    async def pass_login_credentials(self, step, credentials, cookies):
        if "logout&app=oauth" in credentials['end_uri']:
            # 2fa expired, repeat authentication
            return self.authentication_client.authenticate_using_login()

        if self.authentication_client.attempted_to_set_battle_tag:
            self.authentication_client.user_details = await self.backend_client.get_user_info(
            )
            return self.authentication_client.parse_auth_after_setting_battletag(
            )

        cookie_jar = self.authentication_client.parse_cookies(cookies)
        auth_data = await self.authentication_client.get_auth_data_login(
            cookie_jar, credentials)

        try:
            await self.authentication_client.create_session()
            await self.backend_client.refresh_cookies()
        except (BackendNotAvailable, BackendError, NetworkError, UnknownError,
                BackendTimeout) as e:
            raise e
        except Exception:
            raise InvalidCredentials()

        auth_status = await self.backend_client.validate_access_token(
            auth_data.access_token)
        if not ("authorities" in auth_status
                and "IS_AUTHENTICATED_FULLY" in auth_status["authorities"]):
            raise InvalidCredentials()

        self.authentication_client.user_details = await self.backend_client.get_user_info(
        )

        self.authentication_client.set_credentials()

        return self.authentication_client.parse_battletag()

    async def get_owned_games(self):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        def _parse_battlenet_games(
                standard_games: dict,
                cn: bool) -> Dict[BlizzardGame, LicenseType]:
            licenses = {
                None: LicenseType.Unknown,
                "Trial": LicenseType.OtherUserLicense,
                "Good": LicenseType.SinglePurchase,
                "Inactive": LicenseType.SinglePurchase,
                "Banned": LicenseType.SinglePurchase,
                "Free": LicenseType.FreeToPlay
            }
            games = {}

            for standard_game in standard_games["gameAccounts"]:
                title_id = standard_game['titleId']
                try:
                    game = Blizzard.game_by_title_id(title_id, cn)
                except KeyError:
                    log.warning(
                        f"Skipping unknown game with titleId: {title_id}")
                else:
                    games[game] = licenses[standard_game.get(
                        "gameAccountStatus")]

            # Add wow classic if retail wow is present in owned games
            wow_license = games.get(Blizzard['wow'])
            if wow_license is not None:
                games[Blizzard['wow_classic']] = wow_license
            return games

        def _parse_classic_games(
                classic_games: dict) -> Dict[ClassicGame, LicenseType]:
            games = {}
            for classic_game in classic_games["classicGames"]:
                sanitized_name = classic_game["localizedGameName"].replace(
                    u'\xa0', ' ')
                for cg in Blizzard.CLASSIC_GAMES:
                    if cg.name == sanitized_name:
                        games[cg] = LicenseType.SinglePurchase
                        break
                else:
                    log.warning(
                        f"Skipping unknown classic game with name: {sanitized_name}"
                    )
            return games

        cn = self.authentication_client.region == 'cn'

        battlenet_games = _parse_battlenet_games(
            await self.backend_client.get_owned_games(), cn)
        classic_games = _parse_classic_games(
            await self.backend_client.get_owned_classic_games())
        owned_games: Dict[BlizzardGame, LicenseType] = {
            **battlenet_games,
            **classic_games
        }

        for game in Blizzard.try_for_free_games(cn):
            if game not in owned_games:
                owned_games[game] = LicenseType.FreeToPlay

        return [
            Game(game.uid, game.name, None, LicenseInfo(license_type))
            for game, license_type in owned_games.items()
        ]

    async def get_local_games(self):
        timeout = time.time() + 2

        try:
            translated_installed_games = []

            while not self.local_client.games_finished_parsing():
                await asyncio.sleep(0.1)
                if time.time() >= timeout:
                    break

            running_games = self.local_client.get_running_games()
            installed_games = self.local_client.get_installed_games()
            log.info(f"Installed games {installed_games.items()}")
            log.info(f"Running games {running_games}")
            for uid, game in installed_games.items():
                if game.has_galaxy_installed_state:
                    state = LocalGameState.Installed
                    if uid in running_games:
                        state |= LocalGameState.Running
                    translated_installed_games.append(LocalGame(uid, state))
            self.local_client.installed_games_cache = installed_games
            return translated_installed_games

        except Exception as e:
            log.exception(f"failed to get local games: {str(e)}")
            raise

    async def get_game_time(self, game_id, context):
        total_time = None
        last_played_time = None

        blizzard_game = Blizzard[game_id]

        if blizzard_game.name == "Overwatch":
            total_time = await self._get_overwatch_time()
            log.debug(f"Gametime for Overwatch is {total_time} minutes.")

        for config_info in self.local_client.config_parser.games:
            if config_info.uid == blizzard_game.uid:
                if config_info.last_played is not None:
                    last_played_time = int(config_info.last_played)
                break

        return GameTime(game_id, total_time, last_played_time)

    async def _get_overwatch_time(self) -> Union[None, int]:
        log.debug("Fetching playtime for Overwatch...")
        player_data = await self.backend_client.get_ow_player_data()
        if 'message' in player_data:  # user not found
            log.error('No Overwatch profile found.')
            return None
        if player_data['private'] == True:
            log.info('Unable to get data as Overwatch profile is private.')
            return None
        qp_time = player_data['playtime'].get('quickplay')
        if qp_time is None:  # user has not played quick play
            return 0
        if qp_time.count(':') == 1:  # minutes and seconds
            match = re.search('(?:(?P<m>\\d+):)(?P<s>\\d+)', qp_time)
            if match:
                return int(match.group('m'))
        elif qp_time.count(':') == 2:  # hours, minutes and seconds
            match = re.search('(?:(?P<h>\\d+):)(?P<m>\\d+)', qp_time)
            if match:
                return int(match.group('h')) * 60 + int(match.group('m'))
        raise UnknownBackendResponse(
            f'Unknown Overwatch API playtime format: {qp_time}')

    async def _get_wow_achievements(self):
        achievements = []
        try:
            characters_data = await self.backend_client.get_wow_character_data(
            )
            characters_data = characters_data["characters"]

            wow_character_data = await asyncio.gather(
                *[
                    self.backend_client.get_wow_character_achievements(
                        character["realm"], character["name"])
                    for character in characters_data
                ],
                return_exceptions=True,
            )

            for data in wow_character_data:
                if isinstance(data, requests.Timeout) or isinstance(
                        data, requests.ConnectionError):
                    raise data

            wow_achievement_data = [
                list(
                    zip(
                        data["achievements"]["achievementsCompleted"],
                        data["achievements"]["achievementsCompletedTimestamp"],
                    )) for data in wow_character_data if type(data) is dict
            ]

            already_in = set()

            for char_ach in wow_achievement_data:
                for ach in char_ach:
                    if ach[0] not in already_in:
                        achievements.append(
                            Achievement(achievement_id=ach[0],
                                        unlock_time=int(ach[1] / 1000)))
                        already_in.add(ach[0])
        except (AccessTokenExpired, BackendError) as e:
            log.exception(str(e))
        with open('wow.json', 'w') as f:
            f.write(json.dumps(achievements, cls=DataclassJSONEncoder))
        return achievements

    async def _get_sc2_achievements(self):
        account_data = await self.backend_client.get_sc2_player_data(
            self.authentication_client.user_details["id"])

        # TODO what if more sc2 accounts?
        assert len(account_data) == 1
        account_data = account_data[0]

        profile_data = await self.backend_client.get_sc2_profile_data(
            account_data["regionId"], account_data["realmId"],
            account_data["profileId"])

        sc2_achievement_data = [
            Achievement(achievement_id=achievement["achievementId"],
                        unlock_time=achievement["completionDate"])
            for achievement in profile_data["earnedAchievements"]
            if achievement["isComplete"]
        ]

        with open('sc2.json', 'w') as f:
            f.write(json.dumps(sc2_achievement_data, cls=DataclassJSONEncoder))
        return sc2_achievement_data

    # async def get_unlocked_achievements(self, game_id):
    #     if not self.website_client.is_authenticated():
    #         raise AuthenticationRequired()
    #     try:
    #         if game_id == "21298":
    #             return await self._get_sc2_achievements()
    #         elif game_id == "5730135":
    #             return await self._get_wow_achievements()
    #         else:
    #             return []
    #     except requests.Timeout:
    #         raise BackendTimeout()
    #     except requests.ConnectionError:
    #         raise NetworkError()
    #     except Exception as e:
    #         log.exception(str(e))
    #         return []

    async def launch_platform_client(self):
        if self.local_client.is_running():
            log.info(
                "Launch platform client called but client is already running")
            return
        self.local_client.open_battlenet()
        await self.local_client.prevent_battlenet_from_showing()

    async def shutdown_platform_client(self):
        await self.local_client.shutdown_platform_client()

    async def shutdown(self):
        log.info("Plugin shutdown.")
        await self.authentication_client.shutdown()
예제 #4
0
class BNetPlugin(Plugin):
    def __init__(self, reader, writer, token):
        super().__init__(Platform.Battlenet, version, reader, writer, token)
        self.local_client = LocalClient(self._update_statuses)
        self.authentication_client = AuthenticatedHttpClient(self)
        self.backend_client = BackendClient(self, self.authentication_client)

        self.owned_games_cache = []
        self.watched_running_games = set()
        self.local_games_called = False

    async def _notify_about_game_stop(self, game, starting_timeout):
        if not self.local_games_called:
            return
        id_to_watch = game.info.id

        if id_to_watch in self.watched_running_games:
            log.debug(f'Game {id_to_watch} is already watched. Skipping')
            return

        try:
            self.watched_running_games.add(id_to_watch)
            await asyncio.sleep(starting_timeout)
            ProcessProvider().update_games_processes([game])
            log.info(f'Setuping process watcher for {game._processes}')
            loop = asyncio.get_event_loop()
            await loop.run_in_executor(None, game.wait_until_game_stops)
        finally:
            self.update_local_game_status(
                LocalGame(id_to_watch, LocalGameState.Installed))
            self.watched_running_games.remove(id_to_watch)

    def _update_statuses(self, refreshed_games, previous_games):
        if not self.local_games_called:
            return
        for blizz_id, refr in refreshed_games.items():
            prev = previous_games.get(blizz_id, None)

            if prev is None:
                if refr.playable:
                    log.debug('Detected playable game')
                    state = LocalGameState.Installed
                else:
                    log.debug('Detected installation begin')
                    state = LocalGameState.None_
            elif refr.playable and not prev.playable:
                log.debug('Detected playable game')
                state = LocalGameState.Installed
            elif refr.last_played != prev.last_played:
                log.debug('Detected launched game')
                state = LocalGameState.Installed | LocalGameState.Running
                asyncio.create_task(self._notify_about_game_stop(refr, 5))
            else:
                continue

            log.info(f'Changing game {blizz_id} state to {state}')
            self.update_local_game_status(LocalGame(blizz_id, state))

        for blizz_id, prev in previous_games.items():
            refr = refreshed_games.get(blizz_id, None)
            if refr is None:
                log.debug('Detected uninstalled game')
                state = LocalGameState.None_
                self.update_local_game_status(LocalGame(blizz_id, state))

    def log_out(self):
        if self.backend_client:
            asyncio.create_task(self.authentication_client.shutdown())
        self.authentication_client.user_details = None
        self.owned_games_cache = []

    async def open_battlenet_browser(self):
        url = self.authentication_client.blizzard_battlenet_download_url
        log.info(f'Opening battle.net website: {url}')
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(
            None, lambda x: webbrowser.open(x, autoraise=True), url)

    async def install_game(self, game_id):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        installed_game = self.local_client.get_installed_games().get(
            game_id, None)
        if installed_game and os.access(installed_game.install_path, os.F_OK):
            log.warning(
                "Received install command on an already installed game")
            return await self.launch_game(game_id)

        if game_id in Blizzard.legacy_game_ids:
            if SYSTEM == pf.WINDOWS:
                platform = 'windows'
            elif SYSTEM == pf.MACOS:
                platform = 'macos'
            webbrowser.open(
                f"https://www.blizzard.com/download/confirmation?platform={platform}&locale=enUS&version=LIVE&id={game_id}"
            )
            return
        try:
            self.local_client.refresh()
            log.info(f'Installing game of id {game_id}')
            self.local_client.install_game(game_id)
        except ClientNotInstalledError as e:
            log.warning(e)
            await self.open_battlenet_browser()
        except Exception as e:
            log.exception(f"Installing game {game_id} failed: {e}")

    def _open_battlenet_at_id(self, game_id):
        try:
            self.local_client.refresh()
            self.local_client.open_battlenet(game_id)
        except Exception as e:
            log.exception(
                f"Opening battlenet client on specific game_id {game_id} failed {e}"
            )
            try:
                self.local_client.open_battlenet()
            except Exception as e:
                log.exception(f"Opening battlenet client failed {e}")

    async def uninstall_game(self, game_id):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        if game_id == 'wow_classic':
            # attempting to uninstall classic wow through protocol gives you a message that the game cannot
            # be uninstalled through protocol and you should use battle.net
            return self._open_battlenet_at_id(game_id)

        if SYSTEM == pf.MACOS:
            self._open_battlenet_at_id(game_id)
        else:
            try:
                installed_game = self.local_client.get_installed_games().get(
                    game_id, None)

                if installed_game is None or not os.access(
                        installed_game.install_path, os.F_OK):
                    log.error(f'Cannot uninstall {Blizzard[game_id].uid}')
                    self.update_local_game_status(
                        LocalGame(game_id, LocalGameState.None_))
                    return

                if not isinstance(installed_game.info, ClassicGame):
                    if self.local_client.uninstaller is None:
                        raise FileNotFoundError('Uninstaller not found')

                uninstall_tag = installed_game.uninstall_tag
                client_lang = self.local_client.config_parser.locale_language
                self.local_client.uninstaller.uninstall_game(
                    installed_game, uninstall_tag, client_lang)

            except Exception as e:
                log.exception(f'Uninstalling game {game_id} failed: {e}')

    async def launch_game(self, game_id):
        if not self.local_games_called:
            await self.get_local_games()

        try:
            if self.local_client.get_installed_games() is None:
                log.error(f'Launching game that is not installed: {game_id}')
                return await self.install_game(game_id)

            game = self.local_client.get_installed_games().get(game_id, None)
            if game is None:
                log.error(f'Launching game that is not installed: {game_id}')
                return await self.install_game(game_id)

            if isinstance(game.info, ClassicGame):
                log.info(
                    f'Launching game of id: {game_id}, {game} at path {os.path.join(game.install_path, game.info.exe)}'
                )
                if SYSTEM == pf.WINDOWS:
                    subprocess.Popen(
                        os.path.join(game.install_path, game.info.exe))
                elif SYSTEM == pf.MACOS:
                    if not game.info.bundle_id:
                        log.warning(
                            f"{game.name} has no bundle id, help by providing us bundle id of this game"
                        )
                    subprocess.Popen(['open', '-b', game.info.bundle_id])

                self.update_local_game_status(
                    LocalGame(
                        game_id,
                        LocalGameState.Installed | LocalGameState.Running))
                asyncio.create_task(self._notify_about_game_stop(game, 6))
                return

            self.local_client.refresh()
            log.info(f'Launching game of id: {game_id}, {game}')
            await self.local_client.launch_game(game, wait_sec=60)

            self.update_local_game_status(
                LocalGame(game_id,
                          LocalGameState.Installed | LocalGameState.Running))
            self.local_client.close_window()
            asyncio.create_task(self._notify_about_game_stop(game, 3))

        except ClientNotInstalledError as e:
            log.warning(e)
            await self.open_battlenet_browser()
        except TimeoutError as e:
            log.warning(str(e))
        except Exception as e:
            log.exception(f"Launching game {game_id} failed: {e}")

    async def authenticate(self, stored_credentials=None):
        try:
            if stored_credentials:
                auth_data = self.authentication_client.process_stored_credentials(
                    stored_credentials)
                try:
                    await self.authentication_client.create_session()
                    await self.backend_client.refresh_cookies()
                    auth_status = await self.backend_client.validate_access_token(
                        auth_data.access_token)
                except (BackendNotAvailable, BackendError, NetworkError,
                        UnknownError, BackendTimeout) as e:
                    raise e
                except Exception:
                    raise InvalidCredentials()
                if self.authentication_client.validate_auth_status(
                        auth_status):
                    self.authentication_client.user_details = await self.backend_client.get_user_info(
                    )
                return self.authentication_client.parse_user_details()
            else:
                return self.authentication_client.authenticate_using_login()
        except Exception as e:
            raise e

    async def pass_login_credentials(self, step, credentials, cookies):
        if "logout&app=oauth" in credentials['end_uri']:
            # 2fa expired, repeat authentication
            return self.authentication_client.authenticate_using_login()

        if self.authentication_client.attempted_to_set_battle_tag:
            self.authentication_client.user_details = await self.backend_client.get_user_info(
            )
            return self.authentication_client.parse_auth_after_setting_battletag(
            )

        cookie_jar = self.authentication_client.parse_cookies(cookies)
        auth_data = await self.authentication_client.get_auth_data_login(
            cookie_jar, credentials)

        try:
            await self.authentication_client.create_session()
            await self.backend_client.refresh_cookies()
        except (BackendNotAvailable, BackendError, NetworkError, UnknownError,
                BackendTimeout) as e:
            raise e
        except Exception:
            raise InvalidCredentials()

        auth_status = await self.backend_client.validate_access_token(
            auth_data.access_token)
        if not ("authorities" in auth_status
                and "IS_AUTHENTICATED_FULLY" in auth_status["authorities"]):
            raise InvalidCredentials()

        self.authentication_client.user_details = await self.backend_client.get_user_info(
        )

        self.authentication_client.set_credentials()

        return self.authentication_client.parse_battletag()

    async def get_owned_games(self):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        def _parse_classic_games(classic_games):
            for classic_game in classic_games["classicGames"]:
                log.info(f"looking for {classic_game} in classic games")
                try:
                    blizzard_game = Blizzard[
                        classic_game["localizedGameName"].replace(
                            u'\xa0', ' ')]
                    log.info(f"match! {blizzard_game}")
                    classic_game["titleId"] = blizzard_game.uid
                    classic_game["gameAccountStatus"] = "Good"
                except KeyError:
                    continue

            return classic_games

        def _get_not_added_free_games(owned_games):
            owned_games_ids = []
            for game in owned_games:
                if "titleId" in game:
                    owned_games_ids.append(str(game["titleId"]))

            return [{
                "titleId": game.blizzard_id,
                "localizedGameName": game.name,
                "gameAccountStatus": "Free"
            } for game in Blizzard.free_games
                    if game.blizzard_id not in owned_games_ids]

        try:
            games = await self.backend_client.get_owned_games()
            classic_games = _parse_classic_games(
                await self.backend_client.get_owned_classic_games())
            owned_games = games["gameAccounts"] + classic_games["classicGames"]

            # Add wow classic if retail wow is present in owned games
            for owned_game in owned_games.copy():
                if 'titleId' in owned_game:
                    if owned_game['titleId'] == 5730135:
                        owned_games.append({
                            'titleId':
                            'wow_classic',
                            'localizedGameName':
                            'World of Warcraft Classic',
                            'gameAccountStatus':
                            owned_game['gameAccountStatus']
                        })

            free_games_to_add = _get_not_added_free_games(owned_games)
            owned_games += free_games_to_add
            self.owned_games_cache = owned_games
            return [
                Game(
                    str(game["titleId"]),
                    game["localizedGameName"],
                    [],
                    LicenseInfo(License_Map[game["gameAccountStatus"]]),
                ) for game in self.owned_games_cache if "titleId" in game
            ]
        except Exception as e:
            log.exception(f"failed to get owned games: {repr(e)}")
            raise

    async def get_local_games(self):
        timeout = time.time() + 2

        try:
            translated_installed_games = []

            while not self.local_client.games_finished_parsing():
                await asyncio.sleep(0.1)
                if time.time() >= timeout:
                    break

            running_games = self.local_client.get_running_games()
            installed_games = self.local_client.get_installed_games()
            log.info(f"Installed games {installed_games.items()}")
            log.info(f"Running games {running_games}")
            for id_, game in installed_games.items():
                if game.playable:
                    state = LocalGameState.Installed
                    if id_ in running_games:
                        state |= LocalGameState.Running
                else:
                    state = LocalGameState.None_
                translated_installed_games.append(LocalGame(id_, state))
            self.local_client.installed_games_cache = installed_games
            return translated_installed_games

        except Exception as e:
            log.exception(f"failed to get local games: {str(e)}")
            raise

        finally:
            self.local_games_called = True

    async def get_game_time(self, game_id, context):
        game_time_minutes = None
        if game_id == "5272175":
            game_time_minutes = await self._get_overwatch_time()
            log.debug(
                f"Gametime for Overwatch is {game_time_minutes} minutes.")
        return GameTime(game_id, game_time_minutes, None)

    async def _get_overwatch_time(self) -> Union[None, int]:
        log.debug("Fetching playtime for Overwatch...")
        player_data = await self.backend_client.get_ow_player_data()
        if 'message' in player_data:  # user not found... unfortunately no 404 status code is returned :/
            log.error('No Overwatch profile found.')
            return None
        if player_data['private'] == True:
            log.info('Unable to get data as Overwatch profile is private.')
            return None
        qp_time = player_data['playtime']['quickplay']
        if qp_time is None:  # user has not played quick play
            return 0
        if qp_time.count(':') == 1:  # minutes and seconds
            match = re.search('(?:(?P<m>\\d+):)(?P<s>\\d+)', qp_time)
            if match:
                return int(match.group('m'))
        elif qp_time.count(':') == 2:  # hours, minutes and seconds
            match = re.search('(?:(?P<h>\\d+):)(?P<m>\\d+)', qp_time)
            if match:
                return int(match.group('h')) * 60 + int(match.group('m'))
        raise UnknownBackendResponse(
            f'Unknown Overwatch API playtime format: {qp_time}')

    async def _get_wow_achievements(self):
        achievements = []
        try:
            characters_data = await self.backend_client.get_wow_character_data(
            )
            characters_data = characters_data["characters"]

            wow_character_data = await asyncio.gather(
                *[
                    self.backend_client.get_wow_character_achievements(
                        character["realm"], character["name"])
                    for character in characters_data
                ],
                return_exceptions=True,
            )

            for data in wow_character_data:
                if isinstance(data, requests.Timeout) or isinstance(
                        data, requests.ConnectionError):
                    raise data

            wow_achievement_data = [
                list(
                    zip(
                        data["achievements"]["achievementsCompleted"],
                        data["achievements"]["achievementsCompletedTimestamp"],
                    )) for data in wow_character_data if type(data) is dict
            ]

            already_in = set()

            for char_ach in wow_achievement_data:
                for ach in char_ach:
                    if ach[0] not in already_in:
                        achievements.append(
                            Achievement(achievement_id=ach[0],
                                        unlock_time=int(ach[1] / 1000)))
                        already_in.add(ach[0])
        except (AccessTokenExpired, BackendError) as e:
            log.exception(str(e))
        with open('wow.json', 'w') as f:
            f.write(json.dumps(achievements, cls=DataclassJSONEncoder))
        return achievements

    async def _get_sc2_achievements(self):
        account_data = await self.backend_client.get_sc2_player_data(
            self.authentication_client.user_details["id"])

        # TODO what if more sc2 accounts?
        assert len(account_data) == 1
        account_data = account_data[0]

        profile_data = await self.backend_client.get_sc2_profile_data(
            account_data["regionId"], account_data["realmId"],
            account_data["profileId"])

        sc2_achievement_data = [
            Achievement(achievement_id=achievement["achievementId"],
                        unlock_time=achievement["completionDate"])
            for achievement in profile_data["earnedAchievements"]
            if achievement["isComplete"]
        ]

        with open('sc2.json', 'w') as f:
            f.write(json.dumps(sc2_achievement_data, cls=DataclassJSONEncoder))
        return sc2_achievement_data

    # async def get_unlocked_achievements(self, game_id):
    #     if not self.website_client.is_authenticated():
    #         raise AuthenticationRequired()
    #     try:
    #         if game_id == "21298":
    #             return await self._get_sc2_achievements()
    #         elif game_id == "5730135":
    #             return await self._get_wow_achievements()
    #         else:
    #             return []
    #     except requests.Timeout:
    #         raise BackendTimeout()
    #     except requests.ConnectionError:
    #         raise NetworkError()
    #     except Exception as e:
    #         log.exception(str(e))
    #         return []

    async def launch_platform_client(self):
        if self.local_client.is_running():
            log.info(
                "Launch platform client called but client is already running")
            return
        self.local_client.open_battlenet()
        await self.local_client.prevent_battlenet_from_showing()

    async def shutdown_platform_client(self):
        await self.local_client.shutdown_platform_client()

    async def shutdown(self):
        log.info("Plugin shutdown.")
        await self.authentication_client.shutdown()