Module genshin.client.components.hoyolab

Hoyolab component.

Classes

class HoyolabClient (cookies: ForwardRef('http.cookies.BaseCookie[Any]') | Mapping[Any, Any] | str | Sequence[ForwardRef('http.cookies.BaseCookie[Any]') | Mapping[Any, Any] | str] | None = None,
*,
authkey: str | None = None,
lang: Literal['zh-cn', 'zh-tw', 'de-de', 'en-us', 'es-es', 'fr-fr', 'id-id', 'it-it', 'ja-jp', 'ko-kr', 'pt-pt', 'ru-ru', 'th-th', 'vi-vn', 'tr-tr'] = 'en-us',
region: Region = Region.OVERSEAS,
proxy: str | None = None,
game: Game | None = None,
uid: int | None = None,
hoyolab_id: int | None = None,
device_id: str | None = None,
device_fp: str | None = None,
headers: Mapping[str, str] | Mapping[multidict._multidict.istr, str] | multidict._multidict.CIMultiDict | multidict._multidict.CIMultiDictProxy | Iterable[Tuple[str | multidict._multidict.istr, str]] | None = None,
cache: BaseCache | None = None,
debug: bool = False,
on_cookie_update: Callable[[Mapping[str, str]], Awaitable[None] | None] | None = None)
Expand source code
class HoyolabClient(base.BaseClient):
    """Hoyolab component."""

    async def _get_server_region(self, uid: int, game: types.Game) -> str:
        """Fetch the server region of an account from the API."""
        data = await self.request(
            routes.GET_USER_REGION_URL.get_url(),
            params=dict(game_biz=utility.get_prod_game_biz(self.region, game)),
            cache=client_cache.cache_key("server_region", game=game, uid=uid, region=self.region),
        )
        for account in data["list"]:
            if account["game_uid"] == str(uid):
                return account["region"]

        raise ValueError(f"Failed to recognize server for game {game!r} and uid {uid!r}")

    async def _request_announcements(
        self,
        game: types.Game,
        uid: int,
        *,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of game announcements."""
        if game is types.Game.GENSHIN:
            params = dict(
                game="hk4e",
                game_biz="hk4e_global",
                bundle_id="hk4e_global",
                platform="pc",
                region=utility.recognize_genshin_server(uid),
                uid=uid,
                level=8,
                lang=lang or self.lang,
            )
            url = routes.HK4E_URL.get_url()
        elif game is types.Game.ZZZ:
            params = dict(
                game="nap",
                game_biz="nap_global",
                bundle_id="nap_global",
                platform="pc",
                region=utility.recognize_zzz_server(uid),
                level=60,
                lang=lang or self.lang,
                uid=uid,
            )
            url = routes.NAP_URL.get_url()
        elif game is types.Game.STARRAIL:
            params = dict(
                game="hkrpg",
                game_biz="hkrpg_global",
                bundle_id="hkrpg_global",
                platform="pc",
                region=utility.recognize_starrail_server(uid),
                uid=uid,
                level=70,
                lang=lang or self.lang,
                channel_id=1,
            )
            url = routes.HKRPG_URL.get_url()
        else:
            msg = f"{game!r} is not supported yet."
            raise ValueError(msg)

        info, details = await asyncio.gather(
            self.request_hoyolab(
                url / "announcement/api/getAnnList",
                lang=lang,
                params=params,
            ),
            self.request_hoyolab(
                url / "announcement/api/getAnnContent",
                lang=lang,
                params=params,
            ),
        )

        announcements: list[typing.Dict[str, typing.Any]] = []
        extra_list: list[typing.Dict[str, typing.Any]] = (
            info["pic_list"][0]["type_list"] if "pic_list" in info and info["pic_list"] else []
        )

        for sublist in info["list"] + extra_list:
            for ann in sublist["list"]:
                detail = next((i for i in details["list"] if i["ann_id"] == ann["ann_id"]), None)

                # Update existing announcements with new details
                same_title = next((a for a in announcements if a["title"] == ann["title"]), None)
                if same_title is not None:
                    if ann.get("banner"):
                        same_title["banner"] = ann["banner"]
                    if ann.get("img"):
                        same_title["img"] = ann["img"]
                    continue

                announcements.append({**ann, **(detail or {})})

        return [models.Announcement(**i) for i in announcements]

    async def _request_mimo(
        self,
        endpoint: str,
        *,
        method: typing.Optional[str] = None,
        params: typing.Optional[typing.Mapping[str, typing.Any]] = None,
        data: typing.Any = None,
    ) -> typing.Any:
        game_id = params.get("game_id") if params else data.get("game_id")
        if game_id is None and self.game is None:
            raise ValueError("Cannot determine game for this traveling mimo request.")

        if game_id == 2 or self.game is types.Game.GENSHIN:
            url = routes.MIMO_URL.get_url() / "qiuqiu" / endpoint.replace("-", "_")
        else:
            url = routes.MIMO_URL.get_url() / endpoint
        return await self.request(url, method=method, params=params, data=data)

    async def search_users(
        self,
        keyword: str,
        *,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.PartialHoyolabUser]:
        """Search hoyolab users."""
        data = await self.request_bbs(
            "community/search/wapi/search/user",
            lang=lang,
            params=dict(keyword=keyword, page_size=20),
            cache=client_cache.cache_key("search", keyword=keyword, lang=self.lang),
        )
        return [models.PartialHoyolabUser(**i["user"]) for i in data["list"]]

    async def get_hoyolab_user(
        self,
        hoyolab_id: typing.Optional[int] = None,
        *,
        lang: typing.Optional[str] = None,
    ) -> models.FullHoyolabUser:
        """Get a hoyolab user."""
        if self.region == types.Region.OVERSEAS:
            url = "/community/painter/wapi/user/full"
        elif self.region == types.Region.CHINESE:
            url = "/user/wapi/getUserFullInfo"
        else:
            raise TypeError(f"{self.region!r} is not a valid region.")

        data = await self.request_bbs(
            url=url,
            lang=lang,
            params=dict(uid=hoyolab_id) if hoyolab_id else None,
            cache=client_cache.cache_key("hoyolab", uid=hoyolab_id, lang=lang or self.lang),
        )
        return models.FullHoyolabUser(**data["user_info"])

    async def get_recommended_users(self, *, limit: int = 200) -> typing.Sequence[models.PartialHoyolabUser]:
        """Get a list of recommended active users."""
        warnings.warn("This endpoint is removed and an empty list will always be returned", DeprecationWarning)
        return []

    async def get_genshin_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Genshin Impact announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.GENSHIN)
        else:
            uid = uid or 900000005
        return await self._request_announcements(types.Game.GENSHIN, uid, lang=lang)

    async def get_zzz_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Zenless Zone Zero announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.ZZZ)
        else:
            uid = uid or 1300000000
        return await self._request_announcements(types.Game.ZZZ, uid, lang=lang)

    async def get_starrail_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Star Rail announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.STARRAIL)
        else:
            uid = uid or 809162009
        return await self._request_announcements(types.Game.STARRAIL, uid, lang=lang)

    @managers.requires_cookie_token
    async def redeem_code(
        self,
        code: str,
        uid: typing.Optional[int] = None,
        *,
        game: typing.Optional[types.Game] = None,
        lang: typing.Optional[str] = None,
        region: typing.Optional[str] = None,
    ) -> None:
        """Redeems a gift code for the current user."""
        if game is None:
            if self.default_game is None:
                raise RuntimeError("No default game set.")

            game = self.default_game

        if game not in constants.CODE_SUPPORTED_GAMES:
            raise ValueError(f"{game} does not support code redemption.")

        uid = uid or await self._get_uid(game)

        try:
            region = region or utility.recognize_server(uid, game)
        except Exception:
            warnings.warn(f"Failed to recognize server for game {game!r} and uid {uid!r}, fetching from API now.")
            region = await self._get_server_region(uid, game)

        post_request_games = {types.Game.ZZZ, types.Game.STARRAIL}
        game_origins = {
            types.Game.GENSHIN: "https://genshin.hoyoverse.com",
            types.Game.ZZZ: "https://zenless.hoyoverse.com",
            types.Game.STARRAIL: "https://hsr.hoyoverse.com",
            types.Game.TOT: "https://tot.hoyoverse.com",
        }
        headers = {
            "Origin": game_origins[game],
            "Referer": f"{game_origins[game]}/",
        }

        if game in post_request_games:
            data = dict(
                cdkey=code,
                device_uuid=str(uuid.uuid4()),
                game_biz=utility.get_prod_game_biz(self.region, game),
                lang=utility.create_short_lang_code(lang or self.lang),
                platform="4",
                region=region,
                t=int(time.time()),
                uid=uid,
            )
            await self.request(
                routes.CODE_URL.get_url(self.region, game),
                data=data,
                method="POST",
                headers=headers,
            )
        else:
            await self.request(
                routes.CODE_URL.get_url(self.region, game),
                params=dict(
                    uid=uid,
                    region=region,
                    lang=utility.create_short_lang_code(lang or self.lang),
                    cdkey=code,
                    game_biz=utility.get_prod_game_biz(self.region, game),
                ),
                method="GET",
                headers=headers,
            )

    @managers.no_multi
    async def check_in_community(self) -> None:
        """Check in to the hoyolab community and claim your daily 5 community exp."""
        raise RuntimeError("This API is deprecated.")

    @base.region_specific(types.Region.OVERSEAS)
    async def fetch_mi18n(
        self, url: typing.Union[str, yarl.URL], filename: str, *, lang: typing.Optional[str] = None
    ) -> typing.Mapping[str, str]:
        """Fetch a mi18n file."""
        return await self.request(
            yarl.URL(url) / f"{filename}/{filename}-{lang or self.lang}.json",
            cache=client_cache.cache_key("mi18n", filename=filename, url=url, lang=lang or self.lang),
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_games(self, *, lang: typing.Optional[str] = None) -> typing.Sequence[models.MimoGame]:
        """Get a list of Traveling Mimo games."""
        data = await self._request_mimo("index", params=dict(lang=lang or self.lang))
        if self.game is None:
            raise RuntimeError("No default game set.")

        if self.game is types.Game.GENSHIN:
            return [models.MimoGame(**i["act_info"]) for i in data["act_list"]]
        return [models.MimoGame(**i) for i in data["list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def _get_mimo_game_data(
        self, game: typing.Union[typing.Literal["hoyolab"], types.Game]
    ) -> typing.Tuple[int, int]:
        games = await self.get_mimo_games()
        mimo_game = next((i for i in games if i.game == game), None)
        if mimo_game is None:
            raise ValueError(f"Game {game!r} not found in the list of Traveling Mimo games.")
        return mimo_game.id, mimo_game.version_id

    @base.region_specific(types.Region.OVERSEAS)
    async def _parse_mimo_args(
        self,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    ) -> typing.Tuple[int, int]:
        if game_id is None or version_id is None:
            if game is None:
                if self.default_game is None:
                    raise RuntimeError("No default game set.")
                game = self.default_game

            if game not in {types.Game.GENSHIN, types.Game.ZZZ, types.Game.STARRAIL, "hoyolab"}:
                raise ValueError(f"{game!r} does not support Traveling Mimo.")
            game_id, version_id = await self._get_mimo_game_data(game)

        return game_id, version_id

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_tasks(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoTask]:
        """Get a list of Traveling Mimo missions (tasks)."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "task-list",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return [models.MimoTask(**i) for i in data["task_list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def claim_mimo_task_reward(
        self,
        task_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> None:
        """Claim a Traveling Mimo mission (task) reward."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        await self._request_mimo(
            "receive-point",
            params=dict(task_id=task_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST" if game_id == 2 else "GET",
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def finish_mimo_task(
        self,
        task_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> None:
        """Finish a Traveling Mimo mission (task) reward."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        await self._request_mimo(
            "finish-task",
            data=dict(task_id=task_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_shop_items(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoShopItem]:
        """Get a list of Traveling Mimo shop items."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "exchange-list",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return [models.MimoShopItem(**i) for i in data["exchange_award_list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def buy_mimo_shop_item(
        self,
        item_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> str:
        """Buy an item from the Traveling Mimo shop and return a gift code to redeem it."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "exchange",
            data=dict(award_id=item_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )
        return data["exchange_code"]

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_point_count(
        self,
        *,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    ) -> int:
        """Get the current Traveling Mimo point count."""
        game = game or self.default_game
        games = await self.get_mimo_games()
        mimo_game = next((i for i in games if i.game == game), None)
        if mimo_game is None:
            raise ValueError(f"Game {game!r} not found in the list of Traveling Mimo games.")
        return mimo_game.point

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_lottery_info(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> models.MimoLotteryInfo:
        """Get Traveling Mimo lottery info."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "lottery-info",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return models.MimoLotteryInfo(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def draw_mimo_lottery(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> models.MimoLotteryResult:
        """Draw a Traveling Mimo lottery."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "lottery",
            data=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )
        return models.MimoLotteryResult(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_point_history(
        self,
        operation: models.MimoPointOperation,
        *,
        page: int = 1,
        page_size: int = 20,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoPointRecord]:
        """Get the Traveling Mimo point history for the given operation (income or expense)."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "point-record",
            params=dict(
                game_id=game_id,
                lang=lang or self.lang,
                version_id=version_id,
                operator_type=operation.value,
                page=page,
                page_size=page_size,
            ),
        )
        return [models.MimoPointRecord(**i) for i in data["list"]]

    async def reply_to_post(self, content: str, *, post_id: int) -> int:
        """Reply to a community post."""
        data = await self.request_bbs(
            "community/post/wapi/releaseReply",
            data=dict(
                post_id=str(post_id),
                content=f"<p>{content}</p>",
                image_list=[],
                reply_bubble_id="",
                structured_content=json.dumps([{"insert": f"{content}\n"}]),
            ),
            method="POST",
            headers={"x-rpc-device_id": str(uuid.uuid4())},
        )
        return int(data["reply_id"])

    async def delete_reply(self, *, reply_id: int, post_id: int) -> None:
        """Delete a reply."""
        await self.request_bbs(
            "community/post/wapi/deleteReply",
            data=dict(reply_id=reply_id, post_id=post_id),
            method="POST",
        )

    async def _request_join(self, topic_id: int, *, is_cancel: bool) -> None:
        await self.request_bbs(
            "community/topic/wapi/join",
            data=dict(topic_id=topic_id, is_cancel=is_cancel),
            method="POST",
        )

    async def join_topic(self, topic_id: int) -> None:
        """Join a topic."""
        await self._request_join(topic_id, is_cancel=False)

    async def leave_topic(self, topic_id: int) -> None:
        """Leave a topic."""
        await self._request_join(topic_id, is_cancel=True)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_web_events(
        self,
        game: typing.Optional[types.Game] = None,
        *,
        size: int = 15,
        offset: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> list[models.WebEvent]:
        """Get a list of web events."""
        game = game or self.default_game
        if game is None:
            raise ValueError("No default game set.")

        data = await self.request_bbs(
            "community/community_contribution/wapi/event/list",
            params=dict(gids=constants.WEB_EVENT_GAME_IDS[game], size=size, offset=offset or ""),
            lang=lang,
        )
        return [models.WebEvent(**i) for i in data["list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_characters(
        self, *, lang: typing.Optional[str] = None
    ) -> typing.Sequence[models.AccompanyCharacterGame]:
        """Get a list of accompany characters, this endpoint doesn't require cookies."""
        data = await self.request_bbs(
            "community/painter/api/getChannelRoleList",
            cache=client_cache.cache_key("accp_chars", lang=lang or self.lang),
            method="POST",
            lang=lang,
        )
        return [models.AccompanyCharacterGame(**i) for i in data["game_roles_list"]]

    async def _complete_stuid_cookie(self) -> None:
        """Add the stuid cookie if it is missing.

        Accompany endpoints authenticate with the stoken and require the account id
        to be present under the stuid cookie name, otherwise they either fail with
        retcode -100 or silently return no user-specific data.
        """
        if not isinstance(self.cookie_manager, managers.CookieManager):
            return

        cookies = self.cookie_manager.cookies
        if cookies and "stoken" in cookies and "stuid" not in cookies and self.cookie_manager.user_id is not None:
            await self.cookie_manager.update_cookies({"stuid": str(self.cookie_manager.user_id)})

    @base.region_specific(types.Region.OVERSEAS)
    async def accompany_character(self, *, role_id: int, topic_id: int) -> models.AccompanyResult:
        """Accompany a character, role_id and topic_id can be found by calling get_accompany_characters."""
        await self._complete_stuid_cookie()
        data = await self.request_bbs(
            "community/apihub/api/user/accompany/role", params=dict(role_id=role_id, topic_id=topic_id)
        )
        return models.AccompanyResult(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_character_details(
        self, *, topic_id: int, lang: typing.Optional[str] = None
    ) -> models.AccompanyCharacterDetails:
        """Get the page details of an accompany character, topic_id can be found by calling get_accompany_characters."""
        await self._complete_stuid_cookie()
        data = await self.request_bbs(
            "community/painter/api/topic/info",
            params=dict(topic_id=topic_id, scene="SceneAll"),
            lang=lang,
        )
        return models.AccompanyCharacterDetails(**data["info"]["role_info"], stats=data["info"]["stat"])

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_voice_setting(self, *, game_id: int, role_id: int) -> models.AccompanyVoiceSetting:
        """Get the voice and subtitle language setting of an accompany character."""
        data = await self.request_bbs(
            "community/user/api/role_setting/get",
            data=dict(game_id=game_id, role_id=role_id, types=["SettingTypeVoiceScript"]),
            method="POST",
        )
        return models.AccompanyVoiceSetting(**data["setting"]["voice_script_setting"])

    @base.region_specific(types.Region.OVERSEAS)
    async def set_accompany_voice_setting(
        self,
        *,
        game_id: int,
        role_id: int,
        voice_lang: str,
        script_lang: str,
        sync_all_roles: bool = False,
    ) -> None:
        """Set the voice and subtitle language setting of an accompany character."""
        await self._complete_stuid_cookie()
        setting = dict(
            voice_script_setting=dict(
                script_lang=script_lang,
                setting_unix=int(time.time() * 1000),
                voice_lang=voice_lang,
            )
        )
        await self.request_bbs(
            "community/user/api/role_setting/set",
            data=dict(
                game_id=game_id,
                role_id=role_id,
                setting=setting,
                sync_all_roles=sync_all_roles,
                types=["SettingTypeVoiceScript"],
            ),
            method="POST",
        )

Hoyolab component.

Ancestors

Subclasses

Class variables

var logger : logging.Logger

Instance variables

var authkeys : dict[Game, str]
Expand source code
class HoyolabClient(base.BaseClient):
    """Hoyolab component."""

    async def _get_server_region(self, uid: int, game: types.Game) -> str:
        """Fetch the server region of an account from the API."""
        data = await self.request(
            routes.GET_USER_REGION_URL.get_url(),
            params=dict(game_biz=utility.get_prod_game_biz(self.region, game)),
            cache=client_cache.cache_key("server_region", game=game, uid=uid, region=self.region),
        )
        for account in data["list"]:
            if account["game_uid"] == str(uid):
                return account["region"]

        raise ValueError(f"Failed to recognize server for game {game!r} and uid {uid!r}")

    async def _request_announcements(
        self,
        game: types.Game,
        uid: int,
        *,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of game announcements."""
        if game is types.Game.GENSHIN:
            params = dict(
                game="hk4e",
                game_biz="hk4e_global",
                bundle_id="hk4e_global",
                platform="pc",
                region=utility.recognize_genshin_server(uid),
                uid=uid,
                level=8,
                lang=lang or self.lang,
            )
            url = routes.HK4E_URL.get_url()
        elif game is types.Game.ZZZ:
            params = dict(
                game="nap",
                game_biz="nap_global",
                bundle_id="nap_global",
                platform="pc",
                region=utility.recognize_zzz_server(uid),
                level=60,
                lang=lang or self.lang,
                uid=uid,
            )
            url = routes.NAP_URL.get_url()
        elif game is types.Game.STARRAIL:
            params = dict(
                game="hkrpg",
                game_biz="hkrpg_global",
                bundle_id="hkrpg_global",
                platform="pc",
                region=utility.recognize_starrail_server(uid),
                uid=uid,
                level=70,
                lang=lang or self.lang,
                channel_id=1,
            )
            url = routes.HKRPG_URL.get_url()
        else:
            msg = f"{game!r} is not supported yet."
            raise ValueError(msg)

        info, details = await asyncio.gather(
            self.request_hoyolab(
                url / "announcement/api/getAnnList",
                lang=lang,
                params=params,
            ),
            self.request_hoyolab(
                url / "announcement/api/getAnnContent",
                lang=lang,
                params=params,
            ),
        )

        announcements: list[typing.Dict[str, typing.Any]] = []
        extra_list: list[typing.Dict[str, typing.Any]] = (
            info["pic_list"][0]["type_list"] if "pic_list" in info and info["pic_list"] else []
        )

        for sublist in info["list"] + extra_list:
            for ann in sublist["list"]:
                detail = next((i for i in details["list"] if i["ann_id"] == ann["ann_id"]), None)

                # Update existing announcements with new details
                same_title = next((a for a in announcements if a["title"] == ann["title"]), None)
                if same_title is not None:
                    if ann.get("banner"):
                        same_title["banner"] = ann["banner"]
                    if ann.get("img"):
                        same_title["img"] = ann["img"]
                    continue

                announcements.append({**ann, **(detail or {})})

        return [models.Announcement(**i) for i in announcements]

    async def _request_mimo(
        self,
        endpoint: str,
        *,
        method: typing.Optional[str] = None,
        params: typing.Optional[typing.Mapping[str, typing.Any]] = None,
        data: typing.Any = None,
    ) -> typing.Any:
        game_id = params.get("game_id") if params else data.get("game_id")
        if game_id is None and self.game is None:
            raise ValueError("Cannot determine game for this traveling mimo request.")

        if game_id == 2 or self.game is types.Game.GENSHIN:
            url = routes.MIMO_URL.get_url() / "qiuqiu" / endpoint.replace("-", "_")
        else:
            url = routes.MIMO_URL.get_url() / endpoint
        return await self.request(url, method=method, params=params, data=data)

    async def search_users(
        self,
        keyword: str,
        *,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.PartialHoyolabUser]:
        """Search hoyolab users."""
        data = await self.request_bbs(
            "community/search/wapi/search/user",
            lang=lang,
            params=dict(keyword=keyword, page_size=20),
            cache=client_cache.cache_key("search", keyword=keyword, lang=self.lang),
        )
        return [models.PartialHoyolabUser(**i["user"]) for i in data["list"]]

    async def get_hoyolab_user(
        self,
        hoyolab_id: typing.Optional[int] = None,
        *,
        lang: typing.Optional[str] = None,
    ) -> models.FullHoyolabUser:
        """Get a hoyolab user."""
        if self.region == types.Region.OVERSEAS:
            url = "/community/painter/wapi/user/full"
        elif self.region == types.Region.CHINESE:
            url = "/user/wapi/getUserFullInfo"
        else:
            raise TypeError(f"{self.region!r} is not a valid region.")

        data = await self.request_bbs(
            url=url,
            lang=lang,
            params=dict(uid=hoyolab_id) if hoyolab_id else None,
            cache=client_cache.cache_key("hoyolab", uid=hoyolab_id, lang=lang or self.lang),
        )
        return models.FullHoyolabUser(**data["user_info"])

    async def get_recommended_users(self, *, limit: int = 200) -> typing.Sequence[models.PartialHoyolabUser]:
        """Get a list of recommended active users."""
        warnings.warn("This endpoint is removed and an empty list will always be returned", DeprecationWarning)
        return []

    async def get_genshin_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Genshin Impact announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.GENSHIN)
        else:
            uid = uid or 900000005
        return await self._request_announcements(types.Game.GENSHIN, uid, lang=lang)

    async def get_zzz_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Zenless Zone Zero announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.ZZZ)
        else:
            uid = uid or 1300000000
        return await self._request_announcements(types.Game.ZZZ, uid, lang=lang)

    async def get_starrail_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Star Rail announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.STARRAIL)
        else:
            uid = uid or 809162009
        return await self._request_announcements(types.Game.STARRAIL, uid, lang=lang)

    @managers.requires_cookie_token
    async def redeem_code(
        self,
        code: str,
        uid: typing.Optional[int] = None,
        *,
        game: typing.Optional[types.Game] = None,
        lang: typing.Optional[str] = None,
        region: typing.Optional[str] = None,
    ) -> None:
        """Redeems a gift code for the current user."""
        if game is None:
            if self.default_game is None:
                raise RuntimeError("No default game set.")

            game = self.default_game

        if game not in constants.CODE_SUPPORTED_GAMES:
            raise ValueError(f"{game} does not support code redemption.")

        uid = uid or await self._get_uid(game)

        try:
            region = region or utility.recognize_server(uid, game)
        except Exception:
            warnings.warn(f"Failed to recognize server for game {game!r} and uid {uid!r}, fetching from API now.")
            region = await self._get_server_region(uid, game)

        post_request_games = {types.Game.ZZZ, types.Game.STARRAIL}
        game_origins = {
            types.Game.GENSHIN: "https://genshin.hoyoverse.com",
            types.Game.ZZZ: "https://zenless.hoyoverse.com",
            types.Game.STARRAIL: "https://hsr.hoyoverse.com",
            types.Game.TOT: "https://tot.hoyoverse.com",
        }
        headers = {
            "Origin": game_origins[game],
            "Referer": f"{game_origins[game]}/",
        }

        if game in post_request_games:
            data = dict(
                cdkey=code,
                device_uuid=str(uuid.uuid4()),
                game_biz=utility.get_prod_game_biz(self.region, game),
                lang=utility.create_short_lang_code(lang or self.lang),
                platform="4",
                region=region,
                t=int(time.time()),
                uid=uid,
            )
            await self.request(
                routes.CODE_URL.get_url(self.region, game),
                data=data,
                method="POST",
                headers=headers,
            )
        else:
            await self.request(
                routes.CODE_URL.get_url(self.region, game),
                params=dict(
                    uid=uid,
                    region=region,
                    lang=utility.create_short_lang_code(lang or self.lang),
                    cdkey=code,
                    game_biz=utility.get_prod_game_biz(self.region, game),
                ),
                method="GET",
                headers=headers,
            )

    @managers.no_multi
    async def check_in_community(self) -> None:
        """Check in to the hoyolab community and claim your daily 5 community exp."""
        raise RuntimeError("This API is deprecated.")

    @base.region_specific(types.Region.OVERSEAS)
    async def fetch_mi18n(
        self, url: typing.Union[str, yarl.URL], filename: str, *, lang: typing.Optional[str] = None
    ) -> typing.Mapping[str, str]:
        """Fetch a mi18n file."""
        return await self.request(
            yarl.URL(url) / f"{filename}/{filename}-{lang or self.lang}.json",
            cache=client_cache.cache_key("mi18n", filename=filename, url=url, lang=lang or self.lang),
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_games(self, *, lang: typing.Optional[str] = None) -> typing.Sequence[models.MimoGame]:
        """Get a list of Traveling Mimo games."""
        data = await self._request_mimo("index", params=dict(lang=lang or self.lang))
        if self.game is None:
            raise RuntimeError("No default game set.")

        if self.game is types.Game.GENSHIN:
            return [models.MimoGame(**i["act_info"]) for i in data["act_list"]]
        return [models.MimoGame(**i) for i in data["list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def _get_mimo_game_data(
        self, game: typing.Union[typing.Literal["hoyolab"], types.Game]
    ) -> typing.Tuple[int, int]:
        games = await self.get_mimo_games()
        mimo_game = next((i for i in games if i.game == game), None)
        if mimo_game is None:
            raise ValueError(f"Game {game!r} not found in the list of Traveling Mimo games.")
        return mimo_game.id, mimo_game.version_id

    @base.region_specific(types.Region.OVERSEAS)
    async def _parse_mimo_args(
        self,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    ) -> typing.Tuple[int, int]:
        if game_id is None or version_id is None:
            if game is None:
                if self.default_game is None:
                    raise RuntimeError("No default game set.")
                game = self.default_game

            if game not in {types.Game.GENSHIN, types.Game.ZZZ, types.Game.STARRAIL, "hoyolab"}:
                raise ValueError(f"{game!r} does not support Traveling Mimo.")
            game_id, version_id = await self._get_mimo_game_data(game)

        return game_id, version_id

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_tasks(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoTask]:
        """Get a list of Traveling Mimo missions (tasks)."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "task-list",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return [models.MimoTask(**i) for i in data["task_list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def claim_mimo_task_reward(
        self,
        task_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> None:
        """Claim a Traveling Mimo mission (task) reward."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        await self._request_mimo(
            "receive-point",
            params=dict(task_id=task_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST" if game_id == 2 else "GET",
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def finish_mimo_task(
        self,
        task_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> None:
        """Finish a Traveling Mimo mission (task) reward."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        await self._request_mimo(
            "finish-task",
            data=dict(task_id=task_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_shop_items(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoShopItem]:
        """Get a list of Traveling Mimo shop items."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "exchange-list",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return [models.MimoShopItem(**i) for i in data["exchange_award_list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def buy_mimo_shop_item(
        self,
        item_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> str:
        """Buy an item from the Traveling Mimo shop and return a gift code to redeem it."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "exchange",
            data=dict(award_id=item_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )
        return data["exchange_code"]

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_point_count(
        self,
        *,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    ) -> int:
        """Get the current Traveling Mimo point count."""
        game = game or self.default_game
        games = await self.get_mimo_games()
        mimo_game = next((i for i in games if i.game == game), None)
        if mimo_game is None:
            raise ValueError(f"Game {game!r} not found in the list of Traveling Mimo games.")
        return mimo_game.point

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_lottery_info(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> models.MimoLotteryInfo:
        """Get Traveling Mimo lottery info."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "lottery-info",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return models.MimoLotteryInfo(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def draw_mimo_lottery(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> models.MimoLotteryResult:
        """Draw a Traveling Mimo lottery."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "lottery",
            data=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )
        return models.MimoLotteryResult(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_point_history(
        self,
        operation: models.MimoPointOperation,
        *,
        page: int = 1,
        page_size: int = 20,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoPointRecord]:
        """Get the Traveling Mimo point history for the given operation (income or expense)."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "point-record",
            params=dict(
                game_id=game_id,
                lang=lang or self.lang,
                version_id=version_id,
                operator_type=operation.value,
                page=page,
                page_size=page_size,
            ),
        )
        return [models.MimoPointRecord(**i) for i in data["list"]]

    async def reply_to_post(self, content: str, *, post_id: int) -> int:
        """Reply to a community post."""
        data = await self.request_bbs(
            "community/post/wapi/releaseReply",
            data=dict(
                post_id=str(post_id),
                content=f"<p>{content}</p>",
                image_list=[],
                reply_bubble_id="",
                structured_content=json.dumps([{"insert": f"{content}\n"}]),
            ),
            method="POST",
            headers={"x-rpc-device_id": str(uuid.uuid4())},
        )
        return int(data["reply_id"])

    async def delete_reply(self, *, reply_id: int, post_id: int) -> None:
        """Delete a reply."""
        await self.request_bbs(
            "community/post/wapi/deleteReply",
            data=dict(reply_id=reply_id, post_id=post_id),
            method="POST",
        )

    async def _request_join(self, topic_id: int, *, is_cancel: bool) -> None:
        await self.request_bbs(
            "community/topic/wapi/join",
            data=dict(topic_id=topic_id, is_cancel=is_cancel),
            method="POST",
        )

    async def join_topic(self, topic_id: int) -> None:
        """Join a topic."""
        await self._request_join(topic_id, is_cancel=False)

    async def leave_topic(self, topic_id: int) -> None:
        """Leave a topic."""
        await self._request_join(topic_id, is_cancel=True)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_web_events(
        self,
        game: typing.Optional[types.Game] = None,
        *,
        size: int = 15,
        offset: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> list[models.WebEvent]:
        """Get a list of web events."""
        game = game or self.default_game
        if game is None:
            raise ValueError("No default game set.")

        data = await self.request_bbs(
            "community/community_contribution/wapi/event/list",
            params=dict(gids=constants.WEB_EVENT_GAME_IDS[game], size=size, offset=offset or ""),
            lang=lang,
        )
        return [models.WebEvent(**i) for i in data["list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_characters(
        self, *, lang: typing.Optional[str] = None
    ) -> typing.Sequence[models.AccompanyCharacterGame]:
        """Get a list of accompany characters, this endpoint doesn't require cookies."""
        data = await self.request_bbs(
            "community/painter/api/getChannelRoleList",
            cache=client_cache.cache_key("accp_chars", lang=lang or self.lang),
            method="POST",
            lang=lang,
        )
        return [models.AccompanyCharacterGame(**i) for i in data["game_roles_list"]]

    async def _complete_stuid_cookie(self) -> None:
        """Add the stuid cookie if it is missing.

        Accompany endpoints authenticate with the stoken and require the account id
        to be present under the stuid cookie name, otherwise they either fail with
        retcode -100 or silently return no user-specific data.
        """
        if not isinstance(self.cookie_manager, managers.CookieManager):
            return

        cookies = self.cookie_manager.cookies
        if cookies and "stoken" in cookies and "stuid" not in cookies and self.cookie_manager.user_id is not None:
            await self.cookie_manager.update_cookies({"stuid": str(self.cookie_manager.user_id)})

    @base.region_specific(types.Region.OVERSEAS)
    async def accompany_character(self, *, role_id: int, topic_id: int) -> models.AccompanyResult:
        """Accompany a character, role_id and topic_id can be found by calling get_accompany_characters."""
        await self._complete_stuid_cookie()
        data = await self.request_bbs(
            "community/apihub/api/user/accompany/role", params=dict(role_id=role_id, topic_id=topic_id)
        )
        return models.AccompanyResult(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_character_details(
        self, *, topic_id: int, lang: typing.Optional[str] = None
    ) -> models.AccompanyCharacterDetails:
        """Get the page details of an accompany character, topic_id can be found by calling get_accompany_characters."""
        await self._complete_stuid_cookie()
        data = await self.request_bbs(
            "community/painter/api/topic/info",
            params=dict(topic_id=topic_id, scene="SceneAll"),
            lang=lang,
        )
        return models.AccompanyCharacterDetails(**data["info"]["role_info"], stats=data["info"]["stat"])

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_voice_setting(self, *, game_id: int, role_id: int) -> models.AccompanyVoiceSetting:
        """Get the voice and subtitle language setting of an accompany character."""
        data = await self.request_bbs(
            "community/user/api/role_setting/get",
            data=dict(game_id=game_id, role_id=role_id, types=["SettingTypeVoiceScript"]),
            method="POST",
        )
        return models.AccompanyVoiceSetting(**data["setting"]["voice_script_setting"])

    @base.region_specific(types.Region.OVERSEAS)
    async def set_accompany_voice_setting(
        self,
        *,
        game_id: int,
        role_id: int,
        voice_lang: str,
        script_lang: str,
        sync_all_roles: bool = False,
    ) -> None:
        """Set the voice and subtitle language setting of an accompany character."""
        await self._complete_stuid_cookie()
        setting = dict(
            voice_script_setting=dict(
                script_lang=script_lang,
                setting_unix=int(time.time() * 1000),
                voice_lang=voice_lang,
            )
        )
        await self.request_bbs(
            "community/user/api/role_setting/set",
            data=dict(
                game_id=game_id,
                role_id=role_id,
                setting=setting,
                sync_all_roles=sync_all_roles,
                types=["SettingTypeVoiceScript"],
            ),
            method="POST",
        )
var cache : BaseCache
Expand source code
class HoyolabClient(base.BaseClient):
    """Hoyolab component."""

    async def _get_server_region(self, uid: int, game: types.Game) -> str:
        """Fetch the server region of an account from the API."""
        data = await self.request(
            routes.GET_USER_REGION_URL.get_url(),
            params=dict(game_biz=utility.get_prod_game_biz(self.region, game)),
            cache=client_cache.cache_key("server_region", game=game, uid=uid, region=self.region),
        )
        for account in data["list"]:
            if account["game_uid"] == str(uid):
                return account["region"]

        raise ValueError(f"Failed to recognize server for game {game!r} and uid {uid!r}")

    async def _request_announcements(
        self,
        game: types.Game,
        uid: int,
        *,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of game announcements."""
        if game is types.Game.GENSHIN:
            params = dict(
                game="hk4e",
                game_biz="hk4e_global",
                bundle_id="hk4e_global",
                platform="pc",
                region=utility.recognize_genshin_server(uid),
                uid=uid,
                level=8,
                lang=lang or self.lang,
            )
            url = routes.HK4E_URL.get_url()
        elif game is types.Game.ZZZ:
            params = dict(
                game="nap",
                game_biz="nap_global",
                bundle_id="nap_global",
                platform="pc",
                region=utility.recognize_zzz_server(uid),
                level=60,
                lang=lang or self.lang,
                uid=uid,
            )
            url = routes.NAP_URL.get_url()
        elif game is types.Game.STARRAIL:
            params = dict(
                game="hkrpg",
                game_biz="hkrpg_global",
                bundle_id="hkrpg_global",
                platform="pc",
                region=utility.recognize_starrail_server(uid),
                uid=uid,
                level=70,
                lang=lang or self.lang,
                channel_id=1,
            )
            url = routes.HKRPG_URL.get_url()
        else:
            msg = f"{game!r} is not supported yet."
            raise ValueError(msg)

        info, details = await asyncio.gather(
            self.request_hoyolab(
                url / "announcement/api/getAnnList",
                lang=lang,
                params=params,
            ),
            self.request_hoyolab(
                url / "announcement/api/getAnnContent",
                lang=lang,
                params=params,
            ),
        )

        announcements: list[typing.Dict[str, typing.Any]] = []
        extra_list: list[typing.Dict[str, typing.Any]] = (
            info["pic_list"][0]["type_list"] if "pic_list" in info and info["pic_list"] else []
        )

        for sublist in info["list"] + extra_list:
            for ann in sublist["list"]:
                detail = next((i for i in details["list"] if i["ann_id"] == ann["ann_id"]), None)

                # Update existing announcements with new details
                same_title = next((a for a in announcements if a["title"] == ann["title"]), None)
                if same_title is not None:
                    if ann.get("banner"):
                        same_title["banner"] = ann["banner"]
                    if ann.get("img"):
                        same_title["img"] = ann["img"]
                    continue

                announcements.append({**ann, **(detail or {})})

        return [models.Announcement(**i) for i in announcements]

    async def _request_mimo(
        self,
        endpoint: str,
        *,
        method: typing.Optional[str] = None,
        params: typing.Optional[typing.Mapping[str, typing.Any]] = None,
        data: typing.Any = None,
    ) -> typing.Any:
        game_id = params.get("game_id") if params else data.get("game_id")
        if game_id is None and self.game is None:
            raise ValueError("Cannot determine game for this traveling mimo request.")

        if game_id == 2 or self.game is types.Game.GENSHIN:
            url = routes.MIMO_URL.get_url() / "qiuqiu" / endpoint.replace("-", "_")
        else:
            url = routes.MIMO_URL.get_url() / endpoint
        return await self.request(url, method=method, params=params, data=data)

    async def search_users(
        self,
        keyword: str,
        *,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.PartialHoyolabUser]:
        """Search hoyolab users."""
        data = await self.request_bbs(
            "community/search/wapi/search/user",
            lang=lang,
            params=dict(keyword=keyword, page_size=20),
            cache=client_cache.cache_key("search", keyword=keyword, lang=self.lang),
        )
        return [models.PartialHoyolabUser(**i["user"]) for i in data["list"]]

    async def get_hoyolab_user(
        self,
        hoyolab_id: typing.Optional[int] = None,
        *,
        lang: typing.Optional[str] = None,
    ) -> models.FullHoyolabUser:
        """Get a hoyolab user."""
        if self.region == types.Region.OVERSEAS:
            url = "/community/painter/wapi/user/full"
        elif self.region == types.Region.CHINESE:
            url = "/user/wapi/getUserFullInfo"
        else:
            raise TypeError(f"{self.region!r} is not a valid region.")

        data = await self.request_bbs(
            url=url,
            lang=lang,
            params=dict(uid=hoyolab_id) if hoyolab_id else None,
            cache=client_cache.cache_key("hoyolab", uid=hoyolab_id, lang=lang or self.lang),
        )
        return models.FullHoyolabUser(**data["user_info"])

    async def get_recommended_users(self, *, limit: int = 200) -> typing.Sequence[models.PartialHoyolabUser]:
        """Get a list of recommended active users."""
        warnings.warn("This endpoint is removed and an empty list will always be returned", DeprecationWarning)
        return []

    async def get_genshin_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Genshin Impact announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.GENSHIN)
        else:
            uid = uid or 900000005
        return await self._request_announcements(types.Game.GENSHIN, uid, lang=lang)

    async def get_zzz_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Zenless Zone Zero announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.ZZZ)
        else:
            uid = uid or 1300000000
        return await self._request_announcements(types.Game.ZZZ, uid, lang=lang)

    async def get_starrail_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Star Rail announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.STARRAIL)
        else:
            uid = uid or 809162009
        return await self._request_announcements(types.Game.STARRAIL, uid, lang=lang)

    @managers.requires_cookie_token
    async def redeem_code(
        self,
        code: str,
        uid: typing.Optional[int] = None,
        *,
        game: typing.Optional[types.Game] = None,
        lang: typing.Optional[str] = None,
        region: typing.Optional[str] = None,
    ) -> None:
        """Redeems a gift code for the current user."""
        if game is None:
            if self.default_game is None:
                raise RuntimeError("No default game set.")

            game = self.default_game

        if game not in constants.CODE_SUPPORTED_GAMES:
            raise ValueError(f"{game} does not support code redemption.")

        uid = uid or await self._get_uid(game)

        try:
            region = region or utility.recognize_server(uid, game)
        except Exception:
            warnings.warn(f"Failed to recognize server for game {game!r} and uid {uid!r}, fetching from API now.")
            region = await self._get_server_region(uid, game)

        post_request_games = {types.Game.ZZZ, types.Game.STARRAIL}
        game_origins = {
            types.Game.GENSHIN: "https://genshin.hoyoverse.com",
            types.Game.ZZZ: "https://zenless.hoyoverse.com",
            types.Game.STARRAIL: "https://hsr.hoyoverse.com",
            types.Game.TOT: "https://tot.hoyoverse.com",
        }
        headers = {
            "Origin": game_origins[game],
            "Referer": f"{game_origins[game]}/",
        }

        if game in post_request_games:
            data = dict(
                cdkey=code,
                device_uuid=str(uuid.uuid4()),
                game_biz=utility.get_prod_game_biz(self.region, game),
                lang=utility.create_short_lang_code(lang or self.lang),
                platform="4",
                region=region,
                t=int(time.time()),
                uid=uid,
            )
            await self.request(
                routes.CODE_URL.get_url(self.region, game),
                data=data,
                method="POST",
                headers=headers,
            )
        else:
            await self.request(
                routes.CODE_URL.get_url(self.region, game),
                params=dict(
                    uid=uid,
                    region=region,
                    lang=utility.create_short_lang_code(lang or self.lang),
                    cdkey=code,
                    game_biz=utility.get_prod_game_biz(self.region, game),
                ),
                method="GET",
                headers=headers,
            )

    @managers.no_multi
    async def check_in_community(self) -> None:
        """Check in to the hoyolab community and claim your daily 5 community exp."""
        raise RuntimeError("This API is deprecated.")

    @base.region_specific(types.Region.OVERSEAS)
    async def fetch_mi18n(
        self, url: typing.Union[str, yarl.URL], filename: str, *, lang: typing.Optional[str] = None
    ) -> typing.Mapping[str, str]:
        """Fetch a mi18n file."""
        return await self.request(
            yarl.URL(url) / f"{filename}/{filename}-{lang or self.lang}.json",
            cache=client_cache.cache_key("mi18n", filename=filename, url=url, lang=lang or self.lang),
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_games(self, *, lang: typing.Optional[str] = None) -> typing.Sequence[models.MimoGame]:
        """Get a list of Traveling Mimo games."""
        data = await self._request_mimo("index", params=dict(lang=lang or self.lang))
        if self.game is None:
            raise RuntimeError("No default game set.")

        if self.game is types.Game.GENSHIN:
            return [models.MimoGame(**i["act_info"]) for i in data["act_list"]]
        return [models.MimoGame(**i) for i in data["list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def _get_mimo_game_data(
        self, game: typing.Union[typing.Literal["hoyolab"], types.Game]
    ) -> typing.Tuple[int, int]:
        games = await self.get_mimo_games()
        mimo_game = next((i for i in games if i.game == game), None)
        if mimo_game is None:
            raise ValueError(f"Game {game!r} not found in the list of Traveling Mimo games.")
        return mimo_game.id, mimo_game.version_id

    @base.region_specific(types.Region.OVERSEAS)
    async def _parse_mimo_args(
        self,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    ) -> typing.Tuple[int, int]:
        if game_id is None or version_id is None:
            if game is None:
                if self.default_game is None:
                    raise RuntimeError("No default game set.")
                game = self.default_game

            if game not in {types.Game.GENSHIN, types.Game.ZZZ, types.Game.STARRAIL, "hoyolab"}:
                raise ValueError(f"{game!r} does not support Traveling Mimo.")
            game_id, version_id = await self._get_mimo_game_data(game)

        return game_id, version_id

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_tasks(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoTask]:
        """Get a list of Traveling Mimo missions (tasks)."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "task-list",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return [models.MimoTask(**i) for i in data["task_list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def claim_mimo_task_reward(
        self,
        task_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> None:
        """Claim a Traveling Mimo mission (task) reward."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        await self._request_mimo(
            "receive-point",
            params=dict(task_id=task_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST" if game_id == 2 else "GET",
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def finish_mimo_task(
        self,
        task_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> None:
        """Finish a Traveling Mimo mission (task) reward."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        await self._request_mimo(
            "finish-task",
            data=dict(task_id=task_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_shop_items(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoShopItem]:
        """Get a list of Traveling Mimo shop items."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "exchange-list",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return [models.MimoShopItem(**i) for i in data["exchange_award_list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def buy_mimo_shop_item(
        self,
        item_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> str:
        """Buy an item from the Traveling Mimo shop and return a gift code to redeem it."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "exchange",
            data=dict(award_id=item_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )
        return data["exchange_code"]

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_point_count(
        self,
        *,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    ) -> int:
        """Get the current Traveling Mimo point count."""
        game = game or self.default_game
        games = await self.get_mimo_games()
        mimo_game = next((i for i in games if i.game == game), None)
        if mimo_game is None:
            raise ValueError(f"Game {game!r} not found in the list of Traveling Mimo games.")
        return mimo_game.point

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_lottery_info(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> models.MimoLotteryInfo:
        """Get Traveling Mimo lottery info."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "lottery-info",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return models.MimoLotteryInfo(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def draw_mimo_lottery(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> models.MimoLotteryResult:
        """Draw a Traveling Mimo lottery."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "lottery",
            data=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )
        return models.MimoLotteryResult(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_point_history(
        self,
        operation: models.MimoPointOperation,
        *,
        page: int = 1,
        page_size: int = 20,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoPointRecord]:
        """Get the Traveling Mimo point history for the given operation (income or expense)."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "point-record",
            params=dict(
                game_id=game_id,
                lang=lang or self.lang,
                version_id=version_id,
                operator_type=operation.value,
                page=page,
                page_size=page_size,
            ),
        )
        return [models.MimoPointRecord(**i) for i in data["list"]]

    async def reply_to_post(self, content: str, *, post_id: int) -> int:
        """Reply to a community post."""
        data = await self.request_bbs(
            "community/post/wapi/releaseReply",
            data=dict(
                post_id=str(post_id),
                content=f"<p>{content}</p>",
                image_list=[],
                reply_bubble_id="",
                structured_content=json.dumps([{"insert": f"{content}\n"}]),
            ),
            method="POST",
            headers={"x-rpc-device_id": str(uuid.uuid4())},
        )
        return int(data["reply_id"])

    async def delete_reply(self, *, reply_id: int, post_id: int) -> None:
        """Delete a reply."""
        await self.request_bbs(
            "community/post/wapi/deleteReply",
            data=dict(reply_id=reply_id, post_id=post_id),
            method="POST",
        )

    async def _request_join(self, topic_id: int, *, is_cancel: bool) -> None:
        await self.request_bbs(
            "community/topic/wapi/join",
            data=dict(topic_id=topic_id, is_cancel=is_cancel),
            method="POST",
        )

    async def join_topic(self, topic_id: int) -> None:
        """Join a topic."""
        await self._request_join(topic_id, is_cancel=False)

    async def leave_topic(self, topic_id: int) -> None:
        """Leave a topic."""
        await self._request_join(topic_id, is_cancel=True)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_web_events(
        self,
        game: typing.Optional[types.Game] = None,
        *,
        size: int = 15,
        offset: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> list[models.WebEvent]:
        """Get a list of web events."""
        game = game or self.default_game
        if game is None:
            raise ValueError("No default game set.")

        data = await self.request_bbs(
            "community/community_contribution/wapi/event/list",
            params=dict(gids=constants.WEB_EVENT_GAME_IDS[game], size=size, offset=offset or ""),
            lang=lang,
        )
        return [models.WebEvent(**i) for i in data["list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_characters(
        self, *, lang: typing.Optional[str] = None
    ) -> typing.Sequence[models.AccompanyCharacterGame]:
        """Get a list of accompany characters, this endpoint doesn't require cookies."""
        data = await self.request_bbs(
            "community/painter/api/getChannelRoleList",
            cache=client_cache.cache_key("accp_chars", lang=lang or self.lang),
            method="POST",
            lang=lang,
        )
        return [models.AccompanyCharacterGame(**i) for i in data["game_roles_list"]]

    async def _complete_stuid_cookie(self) -> None:
        """Add the stuid cookie if it is missing.

        Accompany endpoints authenticate with the stoken and require the account id
        to be present under the stuid cookie name, otherwise they either fail with
        retcode -100 or silently return no user-specific data.
        """
        if not isinstance(self.cookie_manager, managers.CookieManager):
            return

        cookies = self.cookie_manager.cookies
        if cookies and "stoken" in cookies and "stuid" not in cookies and self.cookie_manager.user_id is not None:
            await self.cookie_manager.update_cookies({"stuid": str(self.cookie_manager.user_id)})

    @base.region_specific(types.Region.OVERSEAS)
    async def accompany_character(self, *, role_id: int, topic_id: int) -> models.AccompanyResult:
        """Accompany a character, role_id and topic_id can be found by calling get_accompany_characters."""
        await self._complete_stuid_cookie()
        data = await self.request_bbs(
            "community/apihub/api/user/accompany/role", params=dict(role_id=role_id, topic_id=topic_id)
        )
        return models.AccompanyResult(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_character_details(
        self, *, topic_id: int, lang: typing.Optional[str] = None
    ) -> models.AccompanyCharacterDetails:
        """Get the page details of an accompany character, topic_id can be found by calling get_accompany_characters."""
        await self._complete_stuid_cookie()
        data = await self.request_bbs(
            "community/painter/api/topic/info",
            params=dict(topic_id=topic_id, scene="SceneAll"),
            lang=lang,
        )
        return models.AccompanyCharacterDetails(**data["info"]["role_info"], stats=data["info"]["stat"])

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_voice_setting(self, *, game_id: int, role_id: int) -> models.AccompanyVoiceSetting:
        """Get the voice and subtitle language setting of an accompany character."""
        data = await self.request_bbs(
            "community/user/api/role_setting/get",
            data=dict(game_id=game_id, role_id=role_id, types=["SettingTypeVoiceScript"]),
            method="POST",
        )
        return models.AccompanyVoiceSetting(**data["setting"]["voice_script_setting"])

    @base.region_specific(types.Region.OVERSEAS)
    async def set_accompany_voice_setting(
        self,
        *,
        game_id: int,
        role_id: int,
        voice_lang: str,
        script_lang: str,
        sync_all_roles: bool = False,
    ) -> None:
        """Set the voice and subtitle language setting of an accompany character."""
        await self._complete_stuid_cookie()
        setting = dict(
            voice_script_setting=dict(
                script_lang=script_lang,
                setting_unix=int(time.time() * 1000),
                voice_lang=voice_lang,
            )
        )
        await self.request_bbs(
            "community/user/api/role_setting/set",
            data=dict(
                game_id=game_id,
                role_id=role_id,
                setting=setting,
                sync_all_roles=sync_all_roles,
                types=["SettingTypeVoiceScript"],
            ),
            method="POST",
        )
var cookie_manager : BaseCookieManager
Expand source code
class HoyolabClient(base.BaseClient):
    """Hoyolab component."""

    async def _get_server_region(self, uid: int, game: types.Game) -> str:
        """Fetch the server region of an account from the API."""
        data = await self.request(
            routes.GET_USER_REGION_URL.get_url(),
            params=dict(game_biz=utility.get_prod_game_biz(self.region, game)),
            cache=client_cache.cache_key("server_region", game=game, uid=uid, region=self.region),
        )
        for account in data["list"]:
            if account["game_uid"] == str(uid):
                return account["region"]

        raise ValueError(f"Failed to recognize server for game {game!r} and uid {uid!r}")

    async def _request_announcements(
        self,
        game: types.Game,
        uid: int,
        *,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of game announcements."""
        if game is types.Game.GENSHIN:
            params = dict(
                game="hk4e",
                game_biz="hk4e_global",
                bundle_id="hk4e_global",
                platform="pc",
                region=utility.recognize_genshin_server(uid),
                uid=uid,
                level=8,
                lang=lang or self.lang,
            )
            url = routes.HK4E_URL.get_url()
        elif game is types.Game.ZZZ:
            params = dict(
                game="nap",
                game_biz="nap_global",
                bundle_id="nap_global",
                platform="pc",
                region=utility.recognize_zzz_server(uid),
                level=60,
                lang=lang or self.lang,
                uid=uid,
            )
            url = routes.NAP_URL.get_url()
        elif game is types.Game.STARRAIL:
            params = dict(
                game="hkrpg",
                game_biz="hkrpg_global",
                bundle_id="hkrpg_global",
                platform="pc",
                region=utility.recognize_starrail_server(uid),
                uid=uid,
                level=70,
                lang=lang or self.lang,
                channel_id=1,
            )
            url = routes.HKRPG_URL.get_url()
        else:
            msg = f"{game!r} is not supported yet."
            raise ValueError(msg)

        info, details = await asyncio.gather(
            self.request_hoyolab(
                url / "announcement/api/getAnnList",
                lang=lang,
                params=params,
            ),
            self.request_hoyolab(
                url / "announcement/api/getAnnContent",
                lang=lang,
                params=params,
            ),
        )

        announcements: list[typing.Dict[str, typing.Any]] = []
        extra_list: list[typing.Dict[str, typing.Any]] = (
            info["pic_list"][0]["type_list"] if "pic_list" in info and info["pic_list"] else []
        )

        for sublist in info["list"] + extra_list:
            for ann in sublist["list"]:
                detail = next((i for i in details["list"] if i["ann_id"] == ann["ann_id"]), None)

                # Update existing announcements with new details
                same_title = next((a for a in announcements if a["title"] == ann["title"]), None)
                if same_title is not None:
                    if ann.get("banner"):
                        same_title["banner"] = ann["banner"]
                    if ann.get("img"):
                        same_title["img"] = ann["img"]
                    continue

                announcements.append({**ann, **(detail or {})})

        return [models.Announcement(**i) for i in announcements]

    async def _request_mimo(
        self,
        endpoint: str,
        *,
        method: typing.Optional[str] = None,
        params: typing.Optional[typing.Mapping[str, typing.Any]] = None,
        data: typing.Any = None,
    ) -> typing.Any:
        game_id = params.get("game_id") if params else data.get("game_id")
        if game_id is None and self.game is None:
            raise ValueError("Cannot determine game for this traveling mimo request.")

        if game_id == 2 or self.game is types.Game.GENSHIN:
            url = routes.MIMO_URL.get_url() / "qiuqiu" / endpoint.replace("-", "_")
        else:
            url = routes.MIMO_URL.get_url() / endpoint
        return await self.request(url, method=method, params=params, data=data)

    async def search_users(
        self,
        keyword: str,
        *,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.PartialHoyolabUser]:
        """Search hoyolab users."""
        data = await self.request_bbs(
            "community/search/wapi/search/user",
            lang=lang,
            params=dict(keyword=keyword, page_size=20),
            cache=client_cache.cache_key("search", keyword=keyword, lang=self.lang),
        )
        return [models.PartialHoyolabUser(**i["user"]) for i in data["list"]]

    async def get_hoyolab_user(
        self,
        hoyolab_id: typing.Optional[int] = None,
        *,
        lang: typing.Optional[str] = None,
    ) -> models.FullHoyolabUser:
        """Get a hoyolab user."""
        if self.region == types.Region.OVERSEAS:
            url = "/community/painter/wapi/user/full"
        elif self.region == types.Region.CHINESE:
            url = "/user/wapi/getUserFullInfo"
        else:
            raise TypeError(f"{self.region!r} is not a valid region.")

        data = await self.request_bbs(
            url=url,
            lang=lang,
            params=dict(uid=hoyolab_id) if hoyolab_id else None,
            cache=client_cache.cache_key("hoyolab", uid=hoyolab_id, lang=lang or self.lang),
        )
        return models.FullHoyolabUser(**data["user_info"])

    async def get_recommended_users(self, *, limit: int = 200) -> typing.Sequence[models.PartialHoyolabUser]:
        """Get a list of recommended active users."""
        warnings.warn("This endpoint is removed and an empty list will always be returned", DeprecationWarning)
        return []

    async def get_genshin_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Genshin Impact announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.GENSHIN)
        else:
            uid = uid or 900000005
        return await self._request_announcements(types.Game.GENSHIN, uid, lang=lang)

    async def get_zzz_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Zenless Zone Zero announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.ZZZ)
        else:
            uid = uid or 1300000000
        return await self._request_announcements(types.Game.ZZZ, uid, lang=lang)

    async def get_starrail_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Star Rail announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.STARRAIL)
        else:
            uid = uid or 809162009
        return await self._request_announcements(types.Game.STARRAIL, uid, lang=lang)

    @managers.requires_cookie_token
    async def redeem_code(
        self,
        code: str,
        uid: typing.Optional[int] = None,
        *,
        game: typing.Optional[types.Game] = None,
        lang: typing.Optional[str] = None,
        region: typing.Optional[str] = None,
    ) -> None:
        """Redeems a gift code for the current user."""
        if game is None:
            if self.default_game is None:
                raise RuntimeError("No default game set.")

            game = self.default_game

        if game not in constants.CODE_SUPPORTED_GAMES:
            raise ValueError(f"{game} does not support code redemption.")

        uid = uid or await self._get_uid(game)

        try:
            region = region or utility.recognize_server(uid, game)
        except Exception:
            warnings.warn(f"Failed to recognize server for game {game!r} and uid {uid!r}, fetching from API now.")
            region = await self._get_server_region(uid, game)

        post_request_games = {types.Game.ZZZ, types.Game.STARRAIL}
        game_origins = {
            types.Game.GENSHIN: "https://genshin.hoyoverse.com",
            types.Game.ZZZ: "https://zenless.hoyoverse.com",
            types.Game.STARRAIL: "https://hsr.hoyoverse.com",
            types.Game.TOT: "https://tot.hoyoverse.com",
        }
        headers = {
            "Origin": game_origins[game],
            "Referer": f"{game_origins[game]}/",
        }

        if game in post_request_games:
            data = dict(
                cdkey=code,
                device_uuid=str(uuid.uuid4()),
                game_biz=utility.get_prod_game_biz(self.region, game),
                lang=utility.create_short_lang_code(lang or self.lang),
                platform="4",
                region=region,
                t=int(time.time()),
                uid=uid,
            )
            await self.request(
                routes.CODE_URL.get_url(self.region, game),
                data=data,
                method="POST",
                headers=headers,
            )
        else:
            await self.request(
                routes.CODE_URL.get_url(self.region, game),
                params=dict(
                    uid=uid,
                    region=region,
                    lang=utility.create_short_lang_code(lang or self.lang),
                    cdkey=code,
                    game_biz=utility.get_prod_game_biz(self.region, game),
                ),
                method="GET",
                headers=headers,
            )

    @managers.no_multi
    async def check_in_community(self) -> None:
        """Check in to the hoyolab community and claim your daily 5 community exp."""
        raise RuntimeError("This API is deprecated.")

    @base.region_specific(types.Region.OVERSEAS)
    async def fetch_mi18n(
        self, url: typing.Union[str, yarl.URL], filename: str, *, lang: typing.Optional[str] = None
    ) -> typing.Mapping[str, str]:
        """Fetch a mi18n file."""
        return await self.request(
            yarl.URL(url) / f"{filename}/{filename}-{lang or self.lang}.json",
            cache=client_cache.cache_key("mi18n", filename=filename, url=url, lang=lang or self.lang),
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_games(self, *, lang: typing.Optional[str] = None) -> typing.Sequence[models.MimoGame]:
        """Get a list of Traveling Mimo games."""
        data = await self._request_mimo("index", params=dict(lang=lang or self.lang))
        if self.game is None:
            raise RuntimeError("No default game set.")

        if self.game is types.Game.GENSHIN:
            return [models.MimoGame(**i["act_info"]) for i in data["act_list"]]
        return [models.MimoGame(**i) for i in data["list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def _get_mimo_game_data(
        self, game: typing.Union[typing.Literal["hoyolab"], types.Game]
    ) -> typing.Tuple[int, int]:
        games = await self.get_mimo_games()
        mimo_game = next((i for i in games if i.game == game), None)
        if mimo_game is None:
            raise ValueError(f"Game {game!r} not found in the list of Traveling Mimo games.")
        return mimo_game.id, mimo_game.version_id

    @base.region_specific(types.Region.OVERSEAS)
    async def _parse_mimo_args(
        self,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    ) -> typing.Tuple[int, int]:
        if game_id is None or version_id is None:
            if game is None:
                if self.default_game is None:
                    raise RuntimeError("No default game set.")
                game = self.default_game

            if game not in {types.Game.GENSHIN, types.Game.ZZZ, types.Game.STARRAIL, "hoyolab"}:
                raise ValueError(f"{game!r} does not support Traveling Mimo.")
            game_id, version_id = await self._get_mimo_game_data(game)

        return game_id, version_id

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_tasks(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoTask]:
        """Get a list of Traveling Mimo missions (tasks)."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "task-list",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return [models.MimoTask(**i) for i in data["task_list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def claim_mimo_task_reward(
        self,
        task_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> None:
        """Claim a Traveling Mimo mission (task) reward."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        await self._request_mimo(
            "receive-point",
            params=dict(task_id=task_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST" if game_id == 2 else "GET",
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def finish_mimo_task(
        self,
        task_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> None:
        """Finish a Traveling Mimo mission (task) reward."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        await self._request_mimo(
            "finish-task",
            data=dict(task_id=task_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_shop_items(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoShopItem]:
        """Get a list of Traveling Mimo shop items."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "exchange-list",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return [models.MimoShopItem(**i) for i in data["exchange_award_list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def buy_mimo_shop_item(
        self,
        item_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> str:
        """Buy an item from the Traveling Mimo shop and return a gift code to redeem it."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "exchange",
            data=dict(award_id=item_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )
        return data["exchange_code"]

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_point_count(
        self,
        *,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    ) -> int:
        """Get the current Traveling Mimo point count."""
        game = game or self.default_game
        games = await self.get_mimo_games()
        mimo_game = next((i for i in games if i.game == game), None)
        if mimo_game is None:
            raise ValueError(f"Game {game!r} not found in the list of Traveling Mimo games.")
        return mimo_game.point

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_lottery_info(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> models.MimoLotteryInfo:
        """Get Traveling Mimo lottery info."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "lottery-info",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return models.MimoLotteryInfo(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def draw_mimo_lottery(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> models.MimoLotteryResult:
        """Draw a Traveling Mimo lottery."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "lottery",
            data=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )
        return models.MimoLotteryResult(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_point_history(
        self,
        operation: models.MimoPointOperation,
        *,
        page: int = 1,
        page_size: int = 20,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoPointRecord]:
        """Get the Traveling Mimo point history for the given operation (income or expense)."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "point-record",
            params=dict(
                game_id=game_id,
                lang=lang or self.lang,
                version_id=version_id,
                operator_type=operation.value,
                page=page,
                page_size=page_size,
            ),
        )
        return [models.MimoPointRecord(**i) for i in data["list"]]

    async def reply_to_post(self, content: str, *, post_id: int) -> int:
        """Reply to a community post."""
        data = await self.request_bbs(
            "community/post/wapi/releaseReply",
            data=dict(
                post_id=str(post_id),
                content=f"<p>{content}</p>",
                image_list=[],
                reply_bubble_id="",
                structured_content=json.dumps([{"insert": f"{content}\n"}]),
            ),
            method="POST",
            headers={"x-rpc-device_id": str(uuid.uuid4())},
        )
        return int(data["reply_id"])

    async def delete_reply(self, *, reply_id: int, post_id: int) -> None:
        """Delete a reply."""
        await self.request_bbs(
            "community/post/wapi/deleteReply",
            data=dict(reply_id=reply_id, post_id=post_id),
            method="POST",
        )

    async def _request_join(self, topic_id: int, *, is_cancel: bool) -> None:
        await self.request_bbs(
            "community/topic/wapi/join",
            data=dict(topic_id=topic_id, is_cancel=is_cancel),
            method="POST",
        )

    async def join_topic(self, topic_id: int) -> None:
        """Join a topic."""
        await self._request_join(topic_id, is_cancel=False)

    async def leave_topic(self, topic_id: int) -> None:
        """Leave a topic."""
        await self._request_join(topic_id, is_cancel=True)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_web_events(
        self,
        game: typing.Optional[types.Game] = None,
        *,
        size: int = 15,
        offset: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> list[models.WebEvent]:
        """Get a list of web events."""
        game = game or self.default_game
        if game is None:
            raise ValueError("No default game set.")

        data = await self.request_bbs(
            "community/community_contribution/wapi/event/list",
            params=dict(gids=constants.WEB_EVENT_GAME_IDS[game], size=size, offset=offset or ""),
            lang=lang,
        )
        return [models.WebEvent(**i) for i in data["list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_characters(
        self, *, lang: typing.Optional[str] = None
    ) -> typing.Sequence[models.AccompanyCharacterGame]:
        """Get a list of accompany characters, this endpoint doesn't require cookies."""
        data = await self.request_bbs(
            "community/painter/api/getChannelRoleList",
            cache=client_cache.cache_key("accp_chars", lang=lang or self.lang),
            method="POST",
            lang=lang,
        )
        return [models.AccompanyCharacterGame(**i) for i in data["game_roles_list"]]

    async def _complete_stuid_cookie(self) -> None:
        """Add the stuid cookie if it is missing.

        Accompany endpoints authenticate with the stoken and require the account id
        to be present under the stuid cookie name, otherwise they either fail with
        retcode -100 or silently return no user-specific data.
        """
        if not isinstance(self.cookie_manager, managers.CookieManager):
            return

        cookies = self.cookie_manager.cookies
        if cookies and "stoken" in cookies and "stuid" not in cookies and self.cookie_manager.user_id is not None:
            await self.cookie_manager.update_cookies({"stuid": str(self.cookie_manager.user_id)})

    @base.region_specific(types.Region.OVERSEAS)
    async def accompany_character(self, *, role_id: int, topic_id: int) -> models.AccompanyResult:
        """Accompany a character, role_id and topic_id can be found by calling get_accompany_characters."""
        await self._complete_stuid_cookie()
        data = await self.request_bbs(
            "community/apihub/api/user/accompany/role", params=dict(role_id=role_id, topic_id=topic_id)
        )
        return models.AccompanyResult(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_character_details(
        self, *, topic_id: int, lang: typing.Optional[str] = None
    ) -> models.AccompanyCharacterDetails:
        """Get the page details of an accompany character, topic_id can be found by calling get_accompany_characters."""
        await self._complete_stuid_cookie()
        data = await self.request_bbs(
            "community/painter/api/topic/info",
            params=dict(topic_id=topic_id, scene="SceneAll"),
            lang=lang,
        )
        return models.AccompanyCharacterDetails(**data["info"]["role_info"], stats=data["info"]["stat"])

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_voice_setting(self, *, game_id: int, role_id: int) -> models.AccompanyVoiceSetting:
        """Get the voice and subtitle language setting of an accompany character."""
        data = await self.request_bbs(
            "community/user/api/role_setting/get",
            data=dict(game_id=game_id, role_id=role_id, types=["SettingTypeVoiceScript"]),
            method="POST",
        )
        return models.AccompanyVoiceSetting(**data["setting"]["voice_script_setting"])

    @base.region_specific(types.Region.OVERSEAS)
    async def set_accompany_voice_setting(
        self,
        *,
        game_id: int,
        role_id: int,
        voice_lang: str,
        script_lang: str,
        sync_all_roles: bool = False,
    ) -> None:
        """Set the voice and subtitle language setting of an accompany character."""
        await self._complete_stuid_cookie()
        setting = dict(
            voice_script_setting=dict(
                script_lang=script_lang,
                setting_unix=int(time.time() * 1000),
                voice_lang=voice_lang,
            )
        )
        await self.request_bbs(
            "community/user/api/role_setting/set",
            data=dict(
                game_id=game_id,
                role_id=role_id,
                setting=setting,
                sync_all_roles=sync_all_roles,
                types=["SettingTypeVoiceScript"],
            ),
            method="POST",
        )
var custom_headers : multidict._multidict.CIMultiDict[str]
Expand source code
class HoyolabClient(base.BaseClient):
    """Hoyolab component."""

    async def _get_server_region(self, uid: int, game: types.Game) -> str:
        """Fetch the server region of an account from the API."""
        data = await self.request(
            routes.GET_USER_REGION_URL.get_url(),
            params=dict(game_biz=utility.get_prod_game_biz(self.region, game)),
            cache=client_cache.cache_key("server_region", game=game, uid=uid, region=self.region),
        )
        for account in data["list"]:
            if account["game_uid"] == str(uid):
                return account["region"]

        raise ValueError(f"Failed to recognize server for game {game!r} and uid {uid!r}")

    async def _request_announcements(
        self,
        game: types.Game,
        uid: int,
        *,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of game announcements."""
        if game is types.Game.GENSHIN:
            params = dict(
                game="hk4e",
                game_biz="hk4e_global",
                bundle_id="hk4e_global",
                platform="pc",
                region=utility.recognize_genshin_server(uid),
                uid=uid,
                level=8,
                lang=lang or self.lang,
            )
            url = routes.HK4E_URL.get_url()
        elif game is types.Game.ZZZ:
            params = dict(
                game="nap",
                game_biz="nap_global",
                bundle_id="nap_global",
                platform="pc",
                region=utility.recognize_zzz_server(uid),
                level=60,
                lang=lang or self.lang,
                uid=uid,
            )
            url = routes.NAP_URL.get_url()
        elif game is types.Game.STARRAIL:
            params = dict(
                game="hkrpg",
                game_biz="hkrpg_global",
                bundle_id="hkrpg_global",
                platform="pc",
                region=utility.recognize_starrail_server(uid),
                uid=uid,
                level=70,
                lang=lang or self.lang,
                channel_id=1,
            )
            url = routes.HKRPG_URL.get_url()
        else:
            msg = f"{game!r} is not supported yet."
            raise ValueError(msg)

        info, details = await asyncio.gather(
            self.request_hoyolab(
                url / "announcement/api/getAnnList",
                lang=lang,
                params=params,
            ),
            self.request_hoyolab(
                url / "announcement/api/getAnnContent",
                lang=lang,
                params=params,
            ),
        )

        announcements: list[typing.Dict[str, typing.Any]] = []
        extra_list: list[typing.Dict[str, typing.Any]] = (
            info["pic_list"][0]["type_list"] if "pic_list" in info and info["pic_list"] else []
        )

        for sublist in info["list"] + extra_list:
            for ann in sublist["list"]:
                detail = next((i for i in details["list"] if i["ann_id"] == ann["ann_id"]), None)

                # Update existing announcements with new details
                same_title = next((a for a in announcements if a["title"] == ann["title"]), None)
                if same_title is not None:
                    if ann.get("banner"):
                        same_title["banner"] = ann["banner"]
                    if ann.get("img"):
                        same_title["img"] = ann["img"]
                    continue

                announcements.append({**ann, **(detail or {})})

        return [models.Announcement(**i) for i in announcements]

    async def _request_mimo(
        self,
        endpoint: str,
        *,
        method: typing.Optional[str] = None,
        params: typing.Optional[typing.Mapping[str, typing.Any]] = None,
        data: typing.Any = None,
    ) -> typing.Any:
        game_id = params.get("game_id") if params else data.get("game_id")
        if game_id is None and self.game is None:
            raise ValueError("Cannot determine game for this traveling mimo request.")

        if game_id == 2 or self.game is types.Game.GENSHIN:
            url = routes.MIMO_URL.get_url() / "qiuqiu" / endpoint.replace("-", "_")
        else:
            url = routes.MIMO_URL.get_url() / endpoint
        return await self.request(url, method=method, params=params, data=data)

    async def search_users(
        self,
        keyword: str,
        *,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.PartialHoyolabUser]:
        """Search hoyolab users."""
        data = await self.request_bbs(
            "community/search/wapi/search/user",
            lang=lang,
            params=dict(keyword=keyword, page_size=20),
            cache=client_cache.cache_key("search", keyword=keyword, lang=self.lang),
        )
        return [models.PartialHoyolabUser(**i["user"]) for i in data["list"]]

    async def get_hoyolab_user(
        self,
        hoyolab_id: typing.Optional[int] = None,
        *,
        lang: typing.Optional[str] = None,
    ) -> models.FullHoyolabUser:
        """Get a hoyolab user."""
        if self.region == types.Region.OVERSEAS:
            url = "/community/painter/wapi/user/full"
        elif self.region == types.Region.CHINESE:
            url = "/user/wapi/getUserFullInfo"
        else:
            raise TypeError(f"{self.region!r} is not a valid region.")

        data = await self.request_bbs(
            url=url,
            lang=lang,
            params=dict(uid=hoyolab_id) if hoyolab_id else None,
            cache=client_cache.cache_key("hoyolab", uid=hoyolab_id, lang=lang or self.lang),
        )
        return models.FullHoyolabUser(**data["user_info"])

    async def get_recommended_users(self, *, limit: int = 200) -> typing.Sequence[models.PartialHoyolabUser]:
        """Get a list of recommended active users."""
        warnings.warn("This endpoint is removed and an empty list will always be returned", DeprecationWarning)
        return []

    async def get_genshin_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Genshin Impact announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.GENSHIN)
        else:
            uid = uid or 900000005
        return await self._request_announcements(types.Game.GENSHIN, uid, lang=lang)

    async def get_zzz_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Zenless Zone Zero announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.ZZZ)
        else:
            uid = uid or 1300000000
        return await self._request_announcements(types.Game.ZZZ, uid, lang=lang)

    async def get_starrail_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Star Rail announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.STARRAIL)
        else:
            uid = uid or 809162009
        return await self._request_announcements(types.Game.STARRAIL, uid, lang=lang)

    @managers.requires_cookie_token
    async def redeem_code(
        self,
        code: str,
        uid: typing.Optional[int] = None,
        *,
        game: typing.Optional[types.Game] = None,
        lang: typing.Optional[str] = None,
        region: typing.Optional[str] = None,
    ) -> None:
        """Redeems a gift code for the current user."""
        if game is None:
            if self.default_game is None:
                raise RuntimeError("No default game set.")

            game = self.default_game

        if game not in constants.CODE_SUPPORTED_GAMES:
            raise ValueError(f"{game} does not support code redemption.")

        uid = uid or await self._get_uid(game)

        try:
            region = region or utility.recognize_server(uid, game)
        except Exception:
            warnings.warn(f"Failed to recognize server for game {game!r} and uid {uid!r}, fetching from API now.")
            region = await self._get_server_region(uid, game)

        post_request_games = {types.Game.ZZZ, types.Game.STARRAIL}
        game_origins = {
            types.Game.GENSHIN: "https://genshin.hoyoverse.com",
            types.Game.ZZZ: "https://zenless.hoyoverse.com",
            types.Game.STARRAIL: "https://hsr.hoyoverse.com",
            types.Game.TOT: "https://tot.hoyoverse.com",
        }
        headers = {
            "Origin": game_origins[game],
            "Referer": f"{game_origins[game]}/",
        }

        if game in post_request_games:
            data = dict(
                cdkey=code,
                device_uuid=str(uuid.uuid4()),
                game_biz=utility.get_prod_game_biz(self.region, game),
                lang=utility.create_short_lang_code(lang or self.lang),
                platform="4",
                region=region,
                t=int(time.time()),
                uid=uid,
            )
            await self.request(
                routes.CODE_URL.get_url(self.region, game),
                data=data,
                method="POST",
                headers=headers,
            )
        else:
            await self.request(
                routes.CODE_URL.get_url(self.region, game),
                params=dict(
                    uid=uid,
                    region=region,
                    lang=utility.create_short_lang_code(lang or self.lang),
                    cdkey=code,
                    game_biz=utility.get_prod_game_biz(self.region, game),
                ),
                method="GET",
                headers=headers,
            )

    @managers.no_multi
    async def check_in_community(self) -> None:
        """Check in to the hoyolab community and claim your daily 5 community exp."""
        raise RuntimeError("This API is deprecated.")

    @base.region_specific(types.Region.OVERSEAS)
    async def fetch_mi18n(
        self, url: typing.Union[str, yarl.URL], filename: str, *, lang: typing.Optional[str] = None
    ) -> typing.Mapping[str, str]:
        """Fetch a mi18n file."""
        return await self.request(
            yarl.URL(url) / f"{filename}/{filename}-{lang or self.lang}.json",
            cache=client_cache.cache_key("mi18n", filename=filename, url=url, lang=lang or self.lang),
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_games(self, *, lang: typing.Optional[str] = None) -> typing.Sequence[models.MimoGame]:
        """Get a list of Traveling Mimo games."""
        data = await self._request_mimo("index", params=dict(lang=lang or self.lang))
        if self.game is None:
            raise RuntimeError("No default game set.")

        if self.game is types.Game.GENSHIN:
            return [models.MimoGame(**i["act_info"]) for i in data["act_list"]]
        return [models.MimoGame(**i) for i in data["list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def _get_mimo_game_data(
        self, game: typing.Union[typing.Literal["hoyolab"], types.Game]
    ) -> typing.Tuple[int, int]:
        games = await self.get_mimo_games()
        mimo_game = next((i for i in games if i.game == game), None)
        if mimo_game is None:
            raise ValueError(f"Game {game!r} not found in the list of Traveling Mimo games.")
        return mimo_game.id, mimo_game.version_id

    @base.region_specific(types.Region.OVERSEAS)
    async def _parse_mimo_args(
        self,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    ) -> typing.Tuple[int, int]:
        if game_id is None or version_id is None:
            if game is None:
                if self.default_game is None:
                    raise RuntimeError("No default game set.")
                game = self.default_game

            if game not in {types.Game.GENSHIN, types.Game.ZZZ, types.Game.STARRAIL, "hoyolab"}:
                raise ValueError(f"{game!r} does not support Traveling Mimo.")
            game_id, version_id = await self._get_mimo_game_data(game)

        return game_id, version_id

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_tasks(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoTask]:
        """Get a list of Traveling Mimo missions (tasks)."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "task-list",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return [models.MimoTask(**i) for i in data["task_list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def claim_mimo_task_reward(
        self,
        task_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> None:
        """Claim a Traveling Mimo mission (task) reward."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        await self._request_mimo(
            "receive-point",
            params=dict(task_id=task_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST" if game_id == 2 else "GET",
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def finish_mimo_task(
        self,
        task_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> None:
        """Finish a Traveling Mimo mission (task) reward."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        await self._request_mimo(
            "finish-task",
            data=dict(task_id=task_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_shop_items(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoShopItem]:
        """Get a list of Traveling Mimo shop items."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "exchange-list",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return [models.MimoShopItem(**i) for i in data["exchange_award_list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def buy_mimo_shop_item(
        self,
        item_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> str:
        """Buy an item from the Traveling Mimo shop and return a gift code to redeem it."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "exchange",
            data=dict(award_id=item_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )
        return data["exchange_code"]

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_point_count(
        self,
        *,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    ) -> int:
        """Get the current Traveling Mimo point count."""
        game = game or self.default_game
        games = await self.get_mimo_games()
        mimo_game = next((i for i in games if i.game == game), None)
        if mimo_game is None:
            raise ValueError(f"Game {game!r} not found in the list of Traveling Mimo games.")
        return mimo_game.point

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_lottery_info(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> models.MimoLotteryInfo:
        """Get Traveling Mimo lottery info."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "lottery-info",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return models.MimoLotteryInfo(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def draw_mimo_lottery(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> models.MimoLotteryResult:
        """Draw a Traveling Mimo lottery."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "lottery",
            data=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )
        return models.MimoLotteryResult(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_point_history(
        self,
        operation: models.MimoPointOperation,
        *,
        page: int = 1,
        page_size: int = 20,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoPointRecord]:
        """Get the Traveling Mimo point history for the given operation (income or expense)."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "point-record",
            params=dict(
                game_id=game_id,
                lang=lang or self.lang,
                version_id=version_id,
                operator_type=operation.value,
                page=page,
                page_size=page_size,
            ),
        )
        return [models.MimoPointRecord(**i) for i in data["list"]]

    async def reply_to_post(self, content: str, *, post_id: int) -> int:
        """Reply to a community post."""
        data = await self.request_bbs(
            "community/post/wapi/releaseReply",
            data=dict(
                post_id=str(post_id),
                content=f"<p>{content}</p>",
                image_list=[],
                reply_bubble_id="",
                structured_content=json.dumps([{"insert": f"{content}\n"}]),
            ),
            method="POST",
            headers={"x-rpc-device_id": str(uuid.uuid4())},
        )
        return int(data["reply_id"])

    async def delete_reply(self, *, reply_id: int, post_id: int) -> None:
        """Delete a reply."""
        await self.request_bbs(
            "community/post/wapi/deleteReply",
            data=dict(reply_id=reply_id, post_id=post_id),
            method="POST",
        )

    async def _request_join(self, topic_id: int, *, is_cancel: bool) -> None:
        await self.request_bbs(
            "community/topic/wapi/join",
            data=dict(topic_id=topic_id, is_cancel=is_cancel),
            method="POST",
        )

    async def join_topic(self, topic_id: int) -> None:
        """Join a topic."""
        await self._request_join(topic_id, is_cancel=False)

    async def leave_topic(self, topic_id: int) -> None:
        """Leave a topic."""
        await self._request_join(topic_id, is_cancel=True)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_web_events(
        self,
        game: typing.Optional[types.Game] = None,
        *,
        size: int = 15,
        offset: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> list[models.WebEvent]:
        """Get a list of web events."""
        game = game or self.default_game
        if game is None:
            raise ValueError("No default game set.")

        data = await self.request_bbs(
            "community/community_contribution/wapi/event/list",
            params=dict(gids=constants.WEB_EVENT_GAME_IDS[game], size=size, offset=offset or ""),
            lang=lang,
        )
        return [models.WebEvent(**i) for i in data["list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_characters(
        self, *, lang: typing.Optional[str] = None
    ) -> typing.Sequence[models.AccompanyCharacterGame]:
        """Get a list of accompany characters, this endpoint doesn't require cookies."""
        data = await self.request_bbs(
            "community/painter/api/getChannelRoleList",
            cache=client_cache.cache_key("accp_chars", lang=lang or self.lang),
            method="POST",
            lang=lang,
        )
        return [models.AccompanyCharacterGame(**i) for i in data["game_roles_list"]]

    async def _complete_stuid_cookie(self) -> None:
        """Add the stuid cookie if it is missing.

        Accompany endpoints authenticate with the stoken and require the account id
        to be present under the stuid cookie name, otherwise they either fail with
        retcode -100 or silently return no user-specific data.
        """
        if not isinstance(self.cookie_manager, managers.CookieManager):
            return

        cookies = self.cookie_manager.cookies
        if cookies and "stoken" in cookies and "stuid" not in cookies and self.cookie_manager.user_id is not None:
            await self.cookie_manager.update_cookies({"stuid": str(self.cookie_manager.user_id)})

    @base.region_specific(types.Region.OVERSEAS)
    async def accompany_character(self, *, role_id: int, topic_id: int) -> models.AccompanyResult:
        """Accompany a character, role_id and topic_id can be found by calling get_accompany_characters."""
        await self._complete_stuid_cookie()
        data = await self.request_bbs(
            "community/apihub/api/user/accompany/role", params=dict(role_id=role_id, topic_id=topic_id)
        )
        return models.AccompanyResult(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_character_details(
        self, *, topic_id: int, lang: typing.Optional[str] = None
    ) -> models.AccompanyCharacterDetails:
        """Get the page details of an accompany character, topic_id can be found by calling get_accompany_characters."""
        await self._complete_stuid_cookie()
        data = await self.request_bbs(
            "community/painter/api/topic/info",
            params=dict(topic_id=topic_id, scene="SceneAll"),
            lang=lang,
        )
        return models.AccompanyCharacterDetails(**data["info"]["role_info"], stats=data["info"]["stat"])

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_voice_setting(self, *, game_id: int, role_id: int) -> models.AccompanyVoiceSetting:
        """Get the voice and subtitle language setting of an accompany character."""
        data = await self.request_bbs(
            "community/user/api/role_setting/get",
            data=dict(game_id=game_id, role_id=role_id, types=["SettingTypeVoiceScript"]),
            method="POST",
        )
        return models.AccompanyVoiceSetting(**data["setting"]["voice_script_setting"])

    @base.region_specific(types.Region.OVERSEAS)
    async def set_accompany_voice_setting(
        self,
        *,
        game_id: int,
        role_id: int,
        voice_lang: str,
        script_lang: str,
        sync_all_roles: bool = False,
    ) -> None:
        """Set the voice and subtitle language setting of an accompany character."""
        await self._complete_stuid_cookie()
        setting = dict(
            voice_script_setting=dict(
                script_lang=script_lang,
                setting_unix=int(time.time() * 1000),
                voice_lang=voice_lang,
            )
        )
        await self.request_bbs(
            "community/user/api/role_setting/set",
            data=dict(
                game_id=game_id,
                role_id=role_id,
                setting=setting,
                sync_all_roles=sync_all_roles,
                types=["SettingTypeVoiceScript"],
            ),
            method="POST",
        )
var uids : dict[Game, int]
Expand source code
class HoyolabClient(base.BaseClient):
    """Hoyolab component."""

    async def _get_server_region(self, uid: int, game: types.Game) -> str:
        """Fetch the server region of an account from the API."""
        data = await self.request(
            routes.GET_USER_REGION_URL.get_url(),
            params=dict(game_biz=utility.get_prod_game_biz(self.region, game)),
            cache=client_cache.cache_key("server_region", game=game, uid=uid, region=self.region),
        )
        for account in data["list"]:
            if account["game_uid"] == str(uid):
                return account["region"]

        raise ValueError(f"Failed to recognize server for game {game!r} and uid {uid!r}")

    async def _request_announcements(
        self,
        game: types.Game,
        uid: int,
        *,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of game announcements."""
        if game is types.Game.GENSHIN:
            params = dict(
                game="hk4e",
                game_biz="hk4e_global",
                bundle_id="hk4e_global",
                platform="pc",
                region=utility.recognize_genshin_server(uid),
                uid=uid,
                level=8,
                lang=lang or self.lang,
            )
            url = routes.HK4E_URL.get_url()
        elif game is types.Game.ZZZ:
            params = dict(
                game="nap",
                game_biz="nap_global",
                bundle_id="nap_global",
                platform="pc",
                region=utility.recognize_zzz_server(uid),
                level=60,
                lang=lang or self.lang,
                uid=uid,
            )
            url = routes.NAP_URL.get_url()
        elif game is types.Game.STARRAIL:
            params = dict(
                game="hkrpg",
                game_biz="hkrpg_global",
                bundle_id="hkrpg_global",
                platform="pc",
                region=utility.recognize_starrail_server(uid),
                uid=uid,
                level=70,
                lang=lang or self.lang,
                channel_id=1,
            )
            url = routes.HKRPG_URL.get_url()
        else:
            msg = f"{game!r} is not supported yet."
            raise ValueError(msg)

        info, details = await asyncio.gather(
            self.request_hoyolab(
                url / "announcement/api/getAnnList",
                lang=lang,
                params=params,
            ),
            self.request_hoyolab(
                url / "announcement/api/getAnnContent",
                lang=lang,
                params=params,
            ),
        )

        announcements: list[typing.Dict[str, typing.Any]] = []
        extra_list: list[typing.Dict[str, typing.Any]] = (
            info["pic_list"][0]["type_list"] if "pic_list" in info and info["pic_list"] else []
        )

        for sublist in info["list"] + extra_list:
            for ann in sublist["list"]:
                detail = next((i for i in details["list"] if i["ann_id"] == ann["ann_id"]), None)

                # Update existing announcements with new details
                same_title = next((a for a in announcements if a["title"] == ann["title"]), None)
                if same_title is not None:
                    if ann.get("banner"):
                        same_title["banner"] = ann["banner"]
                    if ann.get("img"):
                        same_title["img"] = ann["img"]
                    continue

                announcements.append({**ann, **(detail or {})})

        return [models.Announcement(**i) for i in announcements]

    async def _request_mimo(
        self,
        endpoint: str,
        *,
        method: typing.Optional[str] = None,
        params: typing.Optional[typing.Mapping[str, typing.Any]] = None,
        data: typing.Any = None,
    ) -> typing.Any:
        game_id = params.get("game_id") if params else data.get("game_id")
        if game_id is None and self.game is None:
            raise ValueError("Cannot determine game for this traveling mimo request.")

        if game_id == 2 or self.game is types.Game.GENSHIN:
            url = routes.MIMO_URL.get_url() / "qiuqiu" / endpoint.replace("-", "_")
        else:
            url = routes.MIMO_URL.get_url() / endpoint
        return await self.request(url, method=method, params=params, data=data)

    async def search_users(
        self,
        keyword: str,
        *,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.PartialHoyolabUser]:
        """Search hoyolab users."""
        data = await self.request_bbs(
            "community/search/wapi/search/user",
            lang=lang,
            params=dict(keyword=keyword, page_size=20),
            cache=client_cache.cache_key("search", keyword=keyword, lang=self.lang),
        )
        return [models.PartialHoyolabUser(**i["user"]) for i in data["list"]]

    async def get_hoyolab_user(
        self,
        hoyolab_id: typing.Optional[int] = None,
        *,
        lang: typing.Optional[str] = None,
    ) -> models.FullHoyolabUser:
        """Get a hoyolab user."""
        if self.region == types.Region.OVERSEAS:
            url = "/community/painter/wapi/user/full"
        elif self.region == types.Region.CHINESE:
            url = "/user/wapi/getUserFullInfo"
        else:
            raise TypeError(f"{self.region!r} is not a valid region.")

        data = await self.request_bbs(
            url=url,
            lang=lang,
            params=dict(uid=hoyolab_id) if hoyolab_id else None,
            cache=client_cache.cache_key("hoyolab", uid=hoyolab_id, lang=lang or self.lang),
        )
        return models.FullHoyolabUser(**data["user_info"])

    async def get_recommended_users(self, *, limit: int = 200) -> typing.Sequence[models.PartialHoyolabUser]:
        """Get a list of recommended active users."""
        warnings.warn("This endpoint is removed and an empty list will always be returned", DeprecationWarning)
        return []

    async def get_genshin_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Genshin Impact announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.GENSHIN)
        else:
            uid = uid or 900000005
        return await self._request_announcements(types.Game.GENSHIN, uid, lang=lang)

    async def get_zzz_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Zenless Zone Zero announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.ZZZ)
        else:
            uid = uid or 1300000000
        return await self._request_announcements(types.Game.ZZZ, uid, lang=lang)

    async def get_starrail_announcements(
        self,
        *,
        uid: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.Announcement]:
        """Get a list of Star Rail announcements."""
        if self.cookie_manager.multi:
            uid = uid or await self._get_uid(types.Game.STARRAIL)
        else:
            uid = uid or 809162009
        return await self._request_announcements(types.Game.STARRAIL, uid, lang=lang)

    @managers.requires_cookie_token
    async def redeem_code(
        self,
        code: str,
        uid: typing.Optional[int] = None,
        *,
        game: typing.Optional[types.Game] = None,
        lang: typing.Optional[str] = None,
        region: typing.Optional[str] = None,
    ) -> None:
        """Redeems a gift code for the current user."""
        if game is None:
            if self.default_game is None:
                raise RuntimeError("No default game set.")

            game = self.default_game

        if game not in constants.CODE_SUPPORTED_GAMES:
            raise ValueError(f"{game} does not support code redemption.")

        uid = uid or await self._get_uid(game)

        try:
            region = region or utility.recognize_server(uid, game)
        except Exception:
            warnings.warn(f"Failed to recognize server for game {game!r} and uid {uid!r}, fetching from API now.")
            region = await self._get_server_region(uid, game)

        post_request_games = {types.Game.ZZZ, types.Game.STARRAIL}
        game_origins = {
            types.Game.GENSHIN: "https://genshin.hoyoverse.com",
            types.Game.ZZZ: "https://zenless.hoyoverse.com",
            types.Game.STARRAIL: "https://hsr.hoyoverse.com",
            types.Game.TOT: "https://tot.hoyoverse.com",
        }
        headers = {
            "Origin": game_origins[game],
            "Referer": f"{game_origins[game]}/",
        }

        if game in post_request_games:
            data = dict(
                cdkey=code,
                device_uuid=str(uuid.uuid4()),
                game_biz=utility.get_prod_game_biz(self.region, game),
                lang=utility.create_short_lang_code(lang or self.lang),
                platform="4",
                region=region,
                t=int(time.time()),
                uid=uid,
            )
            await self.request(
                routes.CODE_URL.get_url(self.region, game),
                data=data,
                method="POST",
                headers=headers,
            )
        else:
            await self.request(
                routes.CODE_URL.get_url(self.region, game),
                params=dict(
                    uid=uid,
                    region=region,
                    lang=utility.create_short_lang_code(lang or self.lang),
                    cdkey=code,
                    game_biz=utility.get_prod_game_biz(self.region, game),
                ),
                method="GET",
                headers=headers,
            )

    @managers.no_multi
    async def check_in_community(self) -> None:
        """Check in to the hoyolab community and claim your daily 5 community exp."""
        raise RuntimeError("This API is deprecated.")

    @base.region_specific(types.Region.OVERSEAS)
    async def fetch_mi18n(
        self, url: typing.Union[str, yarl.URL], filename: str, *, lang: typing.Optional[str] = None
    ) -> typing.Mapping[str, str]:
        """Fetch a mi18n file."""
        return await self.request(
            yarl.URL(url) / f"{filename}/{filename}-{lang or self.lang}.json",
            cache=client_cache.cache_key("mi18n", filename=filename, url=url, lang=lang or self.lang),
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_games(self, *, lang: typing.Optional[str] = None) -> typing.Sequence[models.MimoGame]:
        """Get a list of Traveling Mimo games."""
        data = await self._request_mimo("index", params=dict(lang=lang or self.lang))
        if self.game is None:
            raise RuntimeError("No default game set.")

        if self.game is types.Game.GENSHIN:
            return [models.MimoGame(**i["act_info"]) for i in data["act_list"]]
        return [models.MimoGame(**i) for i in data["list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def _get_mimo_game_data(
        self, game: typing.Union[typing.Literal["hoyolab"], types.Game]
    ) -> typing.Tuple[int, int]:
        games = await self.get_mimo_games()
        mimo_game = next((i for i in games if i.game == game), None)
        if mimo_game is None:
            raise ValueError(f"Game {game!r} not found in the list of Traveling Mimo games.")
        return mimo_game.id, mimo_game.version_id

    @base.region_specific(types.Region.OVERSEAS)
    async def _parse_mimo_args(
        self,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    ) -> typing.Tuple[int, int]:
        if game_id is None or version_id is None:
            if game is None:
                if self.default_game is None:
                    raise RuntimeError("No default game set.")
                game = self.default_game

            if game not in {types.Game.GENSHIN, types.Game.ZZZ, types.Game.STARRAIL, "hoyolab"}:
                raise ValueError(f"{game!r} does not support Traveling Mimo.")
            game_id, version_id = await self._get_mimo_game_data(game)

        return game_id, version_id

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_tasks(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoTask]:
        """Get a list of Traveling Mimo missions (tasks)."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "task-list",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return [models.MimoTask(**i) for i in data["task_list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def claim_mimo_task_reward(
        self,
        task_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> None:
        """Claim a Traveling Mimo mission (task) reward."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        await self._request_mimo(
            "receive-point",
            params=dict(task_id=task_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST" if game_id == 2 else "GET",
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def finish_mimo_task(
        self,
        task_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> None:
        """Finish a Traveling Mimo mission (task) reward."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        await self._request_mimo(
            "finish-task",
            data=dict(task_id=task_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_shop_items(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoShopItem]:
        """Get a list of Traveling Mimo shop items."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "exchange-list",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return [models.MimoShopItem(**i) for i in data["exchange_award_list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def buy_mimo_shop_item(
        self,
        item_id: int,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> str:
        """Buy an item from the Traveling Mimo shop and return a gift code to redeem it."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "exchange",
            data=dict(award_id=item_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )
        return data["exchange_code"]

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_point_count(
        self,
        *,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    ) -> int:
        """Get the current Traveling Mimo point count."""
        game = game or self.default_game
        games = await self.get_mimo_games()
        mimo_game = next((i for i in games if i.game == game), None)
        if mimo_game is None:
            raise ValueError(f"Game {game!r} not found in the list of Traveling Mimo games.")
        return mimo_game.point

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_lottery_info(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> models.MimoLotteryInfo:
        """Get Traveling Mimo lottery info."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "lottery-info",
            params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        )
        return models.MimoLotteryInfo(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def draw_mimo_lottery(
        self,
        *,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> models.MimoLotteryResult:
        """Draw a Traveling Mimo lottery."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "lottery",
            data=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
            method="POST",
        )
        return models.MimoLotteryResult(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_mimo_point_history(
        self,
        operation: models.MimoPointOperation,
        *,
        page: int = 1,
        page_size: int = 20,
        game_id: typing.Optional[int] = None,
        version_id: typing.Optional[int] = None,
        game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
        lang: typing.Optional[str] = None,
    ) -> typing.Sequence[models.MimoPointRecord]:
        """Get the Traveling Mimo point history for the given operation (income or expense)."""
        game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
        data = await self._request_mimo(
            "point-record",
            params=dict(
                game_id=game_id,
                lang=lang or self.lang,
                version_id=version_id,
                operator_type=operation.value,
                page=page,
                page_size=page_size,
            ),
        )
        return [models.MimoPointRecord(**i) for i in data["list"]]

    async def reply_to_post(self, content: str, *, post_id: int) -> int:
        """Reply to a community post."""
        data = await self.request_bbs(
            "community/post/wapi/releaseReply",
            data=dict(
                post_id=str(post_id),
                content=f"<p>{content}</p>",
                image_list=[],
                reply_bubble_id="",
                structured_content=json.dumps([{"insert": f"{content}\n"}]),
            ),
            method="POST",
            headers={"x-rpc-device_id": str(uuid.uuid4())},
        )
        return int(data["reply_id"])

    async def delete_reply(self, *, reply_id: int, post_id: int) -> None:
        """Delete a reply."""
        await self.request_bbs(
            "community/post/wapi/deleteReply",
            data=dict(reply_id=reply_id, post_id=post_id),
            method="POST",
        )

    async def _request_join(self, topic_id: int, *, is_cancel: bool) -> None:
        await self.request_bbs(
            "community/topic/wapi/join",
            data=dict(topic_id=topic_id, is_cancel=is_cancel),
            method="POST",
        )

    async def join_topic(self, topic_id: int) -> None:
        """Join a topic."""
        await self._request_join(topic_id, is_cancel=False)

    async def leave_topic(self, topic_id: int) -> None:
        """Leave a topic."""
        await self._request_join(topic_id, is_cancel=True)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_web_events(
        self,
        game: typing.Optional[types.Game] = None,
        *,
        size: int = 15,
        offset: typing.Optional[int] = None,
        lang: typing.Optional[str] = None,
    ) -> list[models.WebEvent]:
        """Get a list of web events."""
        game = game or self.default_game
        if game is None:
            raise ValueError("No default game set.")

        data = await self.request_bbs(
            "community/community_contribution/wapi/event/list",
            params=dict(gids=constants.WEB_EVENT_GAME_IDS[game], size=size, offset=offset or ""),
            lang=lang,
        )
        return [models.WebEvent(**i) for i in data["list"]]

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_characters(
        self, *, lang: typing.Optional[str] = None
    ) -> typing.Sequence[models.AccompanyCharacterGame]:
        """Get a list of accompany characters, this endpoint doesn't require cookies."""
        data = await self.request_bbs(
            "community/painter/api/getChannelRoleList",
            cache=client_cache.cache_key("accp_chars", lang=lang or self.lang),
            method="POST",
            lang=lang,
        )
        return [models.AccompanyCharacterGame(**i) for i in data["game_roles_list"]]

    async def _complete_stuid_cookie(self) -> None:
        """Add the stuid cookie if it is missing.

        Accompany endpoints authenticate with the stoken and require the account id
        to be present under the stuid cookie name, otherwise they either fail with
        retcode -100 or silently return no user-specific data.
        """
        if not isinstance(self.cookie_manager, managers.CookieManager):
            return

        cookies = self.cookie_manager.cookies
        if cookies and "stoken" in cookies and "stuid" not in cookies and self.cookie_manager.user_id is not None:
            await self.cookie_manager.update_cookies({"stuid": str(self.cookie_manager.user_id)})

    @base.region_specific(types.Region.OVERSEAS)
    async def accompany_character(self, *, role_id: int, topic_id: int) -> models.AccompanyResult:
        """Accompany a character, role_id and topic_id can be found by calling get_accompany_characters."""
        await self._complete_stuid_cookie()
        data = await self.request_bbs(
            "community/apihub/api/user/accompany/role", params=dict(role_id=role_id, topic_id=topic_id)
        )
        return models.AccompanyResult(**data)

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_character_details(
        self, *, topic_id: int, lang: typing.Optional[str] = None
    ) -> models.AccompanyCharacterDetails:
        """Get the page details of an accompany character, topic_id can be found by calling get_accompany_characters."""
        await self._complete_stuid_cookie()
        data = await self.request_bbs(
            "community/painter/api/topic/info",
            params=dict(topic_id=topic_id, scene="SceneAll"),
            lang=lang,
        )
        return models.AccompanyCharacterDetails(**data["info"]["role_info"], stats=data["info"]["stat"])

    @base.region_specific(types.Region.OVERSEAS)
    async def get_accompany_voice_setting(self, *, game_id: int, role_id: int) -> models.AccompanyVoiceSetting:
        """Get the voice and subtitle language setting of an accompany character."""
        data = await self.request_bbs(
            "community/user/api/role_setting/get",
            data=dict(game_id=game_id, role_id=role_id, types=["SettingTypeVoiceScript"]),
            method="POST",
        )
        return models.AccompanyVoiceSetting(**data["setting"]["voice_script_setting"])

    @base.region_specific(types.Region.OVERSEAS)
    async def set_accompany_voice_setting(
        self,
        *,
        game_id: int,
        role_id: int,
        voice_lang: str,
        script_lang: str,
        sync_all_roles: bool = False,
    ) -> None:
        """Set the voice and subtitle language setting of an accompany character."""
        await self._complete_stuid_cookie()
        setting = dict(
            voice_script_setting=dict(
                script_lang=script_lang,
                setting_unix=int(time.time() * 1000),
                voice_lang=voice_lang,
            )
        )
        await self.request_bbs(
            "community/user/api/role_setting/set",
            data=dict(
                game_id=game_id,
                role_id=role_id,
                setting=setting,
                sync_all_roles=sync_all_roles,
                types=["SettingTypeVoiceScript"],
            ),
            method="POST",
        )

Methods

async def accompany_character(self, *, role_id: int, topic_id: int) ‑> AccompanyResult
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def accompany_character(self, *, role_id: int, topic_id: int) -> models.AccompanyResult:
    """Accompany a character, role_id and topic_id can be found by calling get_accompany_characters."""
    await self._complete_stuid_cookie()
    data = await self.request_bbs(
        "community/apihub/api/user/accompany/role", params=dict(role_id=role_id, topic_id=topic_id)
    )
    return models.AccompanyResult(**data)

Accompany a character, role_id and topic_id can be found by calling get_accompany_characters.

async def buy_mimo_shop_item(self,
item_id: int,
*,
game_id: int | None = None,
version_id: int | None = None,
game: Literal['hoyolab'] | Game | None = None,
lang: str | None = None) ‑> str
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def buy_mimo_shop_item(
    self,
    item_id: int,
    *,
    game_id: typing.Optional[int] = None,
    version_id: typing.Optional[int] = None,
    game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    lang: typing.Optional[str] = None,
) -> str:
    """Buy an item from the Traveling Mimo shop and return a gift code to redeem it."""
    game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
    data = await self._request_mimo(
        "exchange",
        data=dict(award_id=item_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
        method="POST",
    )
    return data["exchange_code"]

Buy an item from the Traveling Mimo shop and return a gift code to redeem it.

async def check_in_community(self) ‑> None
Expand source code
@managers.no_multi
async def check_in_community(self) -> None:
    """Check in to the hoyolab community and claim your daily 5 community exp."""
    raise RuntimeError("This API is deprecated.")

Check in to the hoyolab community and claim your daily 5 community exp.

async def claim_mimo_task_reward(self,
task_id: int,
*,
game_id: int | None = None,
version_id: int | None = None,
game: Literal['hoyolab'] | Game | None = None,
lang: str | None = None) ‑> None
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def claim_mimo_task_reward(
    self,
    task_id: int,
    *,
    game_id: typing.Optional[int] = None,
    version_id: typing.Optional[int] = None,
    game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    lang: typing.Optional[str] = None,
) -> None:
    """Claim a Traveling Mimo mission (task) reward."""
    game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
    await self._request_mimo(
        "receive-point",
        params=dict(task_id=task_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
        method="POST" if game_id == 2 else "GET",
    )

Claim a Traveling Mimo mission (task) reward.

async def delete_reply(self, *, reply_id: int, post_id: int) ‑> None
Expand source code
async def delete_reply(self, *, reply_id: int, post_id: int) -> None:
    """Delete a reply."""
    await self.request_bbs(
        "community/post/wapi/deleteReply",
        data=dict(reply_id=reply_id, post_id=post_id),
        method="POST",
    )

Delete a reply.

async def draw_mimo_lottery(self,
*,
game_id: int | None = None,
version_id: int | None = None,
game: Literal['hoyolab'] | Game | None = None,
lang: str | None = None) ‑> MimoLotteryResult
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def draw_mimo_lottery(
    self,
    *,
    game_id: typing.Optional[int] = None,
    version_id: typing.Optional[int] = None,
    game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    lang: typing.Optional[str] = None,
) -> models.MimoLotteryResult:
    """Draw a Traveling Mimo lottery."""
    game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
    data = await self._request_mimo(
        "lottery",
        data=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
        method="POST",
    )
    return models.MimoLotteryResult(**data)

Draw a Traveling Mimo lottery.

async def fetch_mi18n(self, url: str | yarl.URL, filename: str, *, lang: str | None = None) ‑> Mapping[str, str]
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def fetch_mi18n(
    self, url: typing.Union[str, yarl.URL], filename: str, *, lang: typing.Optional[str] = None
) -> typing.Mapping[str, str]:
    """Fetch a mi18n file."""
    return await self.request(
        yarl.URL(url) / f"{filename}/{filename}-{lang or self.lang}.json",
        cache=client_cache.cache_key("mi18n", filename=filename, url=url, lang=lang or self.lang),
    )

Fetch a mi18n file.

async def finish_mimo_task(self,
task_id: int,
*,
game_id: int | None = None,
version_id: int | None = None,
game: Literal['hoyolab'] | Game | None = None,
lang: str | None = None) ‑> None
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def finish_mimo_task(
    self,
    task_id: int,
    *,
    game_id: typing.Optional[int] = None,
    version_id: typing.Optional[int] = None,
    game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    lang: typing.Optional[str] = None,
) -> None:
    """Finish a Traveling Mimo mission (task) reward."""
    game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
    await self._request_mimo(
        "finish-task",
        data=dict(task_id=task_id, game_id=game_id, lang=lang or self.lang, version_id=version_id),
        method="POST",
    )

Finish a Traveling Mimo mission (task) reward.

async def get_accompany_character_details(self, *, topic_id: int, lang: str | None = None) ‑> AccompanyCharacterDetails
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def get_accompany_character_details(
    self, *, topic_id: int, lang: typing.Optional[str] = None
) -> models.AccompanyCharacterDetails:
    """Get the page details of an accompany character, topic_id can be found by calling get_accompany_characters."""
    await self._complete_stuid_cookie()
    data = await self.request_bbs(
        "community/painter/api/topic/info",
        params=dict(topic_id=topic_id, scene="SceneAll"),
        lang=lang,
    )
    return models.AccompanyCharacterDetails(**data["info"]["role_info"], stats=data["info"]["stat"])

Get the page details of an accompany character, topic_id can be found by calling get_accompany_characters.

async def get_accompany_characters(self, *, lang: str | None = None) ‑> Sequence[AccompanyCharacterGame]
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def get_accompany_characters(
    self, *, lang: typing.Optional[str] = None
) -> typing.Sequence[models.AccompanyCharacterGame]:
    """Get a list of accompany characters, this endpoint doesn't require cookies."""
    data = await self.request_bbs(
        "community/painter/api/getChannelRoleList",
        cache=client_cache.cache_key("accp_chars", lang=lang or self.lang),
        method="POST",
        lang=lang,
    )
    return [models.AccompanyCharacterGame(**i) for i in data["game_roles_list"]]

Get a list of accompany characters, this endpoint doesn't require cookies.

async def get_accompany_voice_setting(self, *, game_id: int, role_id: int) ‑> AccompanyVoiceSetting
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def get_accompany_voice_setting(self, *, game_id: int, role_id: int) -> models.AccompanyVoiceSetting:
    """Get the voice and subtitle language setting of an accompany character."""
    data = await self.request_bbs(
        "community/user/api/role_setting/get",
        data=dict(game_id=game_id, role_id=role_id, types=["SettingTypeVoiceScript"]),
        method="POST",
    )
    return models.AccompanyVoiceSetting(**data["setting"]["voice_script_setting"])

Get the voice and subtitle language setting of an accompany character.

async def get_genshin_announcements(self, *, uid: int | None = None, lang: str | None = None) ‑> Sequence[Announcement]
Expand source code
async def get_genshin_announcements(
    self,
    *,
    uid: typing.Optional[int] = None,
    lang: typing.Optional[str] = None,
) -> typing.Sequence[models.Announcement]:
    """Get a list of Genshin Impact announcements."""
    if self.cookie_manager.multi:
        uid = uid or await self._get_uid(types.Game.GENSHIN)
    else:
        uid = uid or 900000005
    return await self._request_announcements(types.Game.GENSHIN, uid, lang=lang)

Get a list of Genshin Impact announcements.

async def get_hoyolab_user(self, hoyolab_id: int | None = None, *, lang: str | None = None) ‑> FullHoyolabUser
Expand source code
async def get_hoyolab_user(
    self,
    hoyolab_id: typing.Optional[int] = None,
    *,
    lang: typing.Optional[str] = None,
) -> models.FullHoyolabUser:
    """Get a hoyolab user."""
    if self.region == types.Region.OVERSEAS:
        url = "/community/painter/wapi/user/full"
    elif self.region == types.Region.CHINESE:
        url = "/user/wapi/getUserFullInfo"
    else:
        raise TypeError(f"{self.region!r} is not a valid region.")

    data = await self.request_bbs(
        url=url,
        lang=lang,
        params=dict(uid=hoyolab_id) if hoyolab_id else None,
        cache=client_cache.cache_key("hoyolab", uid=hoyolab_id, lang=lang or self.lang),
    )
    return models.FullHoyolabUser(**data["user_info"])

Get a hoyolab user.

async def get_mimo_games(self, *, lang: str | None = None) ‑> Sequence[MimoGame]
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def get_mimo_games(self, *, lang: typing.Optional[str] = None) -> typing.Sequence[models.MimoGame]:
    """Get a list of Traveling Mimo games."""
    data = await self._request_mimo("index", params=dict(lang=lang or self.lang))
    if self.game is None:
        raise RuntimeError("No default game set.")

    if self.game is types.Game.GENSHIN:
        return [models.MimoGame(**i["act_info"]) for i in data["act_list"]]
    return [models.MimoGame(**i) for i in data["list"]]

Get a list of Traveling Mimo games.

async def get_mimo_lottery_info(self,
*,
game_id: int | None = None,
version_id: int | None = None,
game: Literal['hoyolab'] | Game | None = None,
lang: str | None = None) ‑> MimoLotteryInfo
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def get_mimo_lottery_info(
    self,
    *,
    game_id: typing.Optional[int] = None,
    version_id: typing.Optional[int] = None,
    game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    lang: typing.Optional[str] = None,
) -> models.MimoLotteryInfo:
    """Get Traveling Mimo lottery info."""
    game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
    data = await self._request_mimo(
        "lottery-info",
        params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
    )
    return models.MimoLotteryInfo(**data)

Get Traveling Mimo lottery info.

async def get_mimo_point_count(self,
*,
game: Literal['hoyolab'] | Game | None = None) ‑> int
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def get_mimo_point_count(
    self,
    *,
    game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
) -> int:
    """Get the current Traveling Mimo point count."""
    game = game or self.default_game
    games = await self.get_mimo_games()
    mimo_game = next((i for i in games if i.game == game), None)
    if mimo_game is None:
        raise ValueError(f"Game {game!r} not found in the list of Traveling Mimo games.")
    return mimo_game.point

Get the current Traveling Mimo point count.

async def get_mimo_point_history(self,
operation: MimoPointOperation,
*,
page: int = 1,
page_size: int = 20,
game_id: int | None = None,
version_id: int | None = None,
game: Literal['hoyolab'] | Game | None = None,
lang: str | None = None) ‑> Sequence[MimoPointRecord]
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def get_mimo_point_history(
    self,
    operation: models.MimoPointOperation,
    *,
    page: int = 1,
    page_size: int = 20,
    game_id: typing.Optional[int] = None,
    version_id: typing.Optional[int] = None,
    game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    lang: typing.Optional[str] = None,
) -> typing.Sequence[models.MimoPointRecord]:
    """Get the Traveling Mimo point history for the given operation (income or expense)."""
    game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
    data = await self._request_mimo(
        "point-record",
        params=dict(
            game_id=game_id,
            lang=lang or self.lang,
            version_id=version_id,
            operator_type=operation.value,
            page=page,
            page_size=page_size,
        ),
    )
    return [models.MimoPointRecord(**i) for i in data["list"]]

Get the Traveling Mimo point history for the given operation (income or expense).

async def get_mimo_shop_items(self,
*,
game_id: int | None = None,
version_id: int | None = None,
game: Literal['hoyolab'] | Game | None = None,
lang: str | None = None) ‑> Sequence[MimoShopItem]
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def get_mimo_shop_items(
    self,
    *,
    game_id: typing.Optional[int] = None,
    version_id: typing.Optional[int] = None,
    game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    lang: typing.Optional[str] = None,
) -> typing.Sequence[models.MimoShopItem]:
    """Get a list of Traveling Mimo shop items."""
    game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
    data = await self._request_mimo(
        "exchange-list",
        params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
    )
    return [models.MimoShopItem(**i) for i in data["exchange_award_list"]]

Get a list of Traveling Mimo shop items.

async def get_mimo_tasks(self,
*,
game_id: int | None = None,
version_id: int | None = None,
game: Literal['hoyolab'] | Game | None = None,
lang: str | None = None) ‑> Sequence[MimoTask]
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def get_mimo_tasks(
    self,
    *,
    game_id: typing.Optional[int] = None,
    version_id: typing.Optional[int] = None,
    game: typing.Optional[typing.Union[typing.Literal["hoyolab"], types.Game]] = None,
    lang: typing.Optional[str] = None,
) -> typing.Sequence[models.MimoTask]:
    """Get a list of Traveling Mimo missions (tasks)."""
    game_id, version_id = await self._parse_mimo_args(game_id, version_id, game)
    data = await self._request_mimo(
        "task-list",
        params=dict(game_id=game_id, lang=lang or self.lang, version_id=version_id),
    )
    return [models.MimoTask(**i) for i in data["task_list"]]

Get a list of Traveling Mimo missions (tasks).

Expand source code
async def get_recommended_users(self, *, limit: int = 200) -> typing.Sequence[models.PartialHoyolabUser]:
    """Get a list of recommended active users."""
    warnings.warn("This endpoint is removed and an empty list will always be returned", DeprecationWarning)
    return []

Get a list of recommended active users.

async def get_starrail_announcements(self, *, uid: int | None = None, lang: str | None = None) ‑> Sequence[Announcement]
Expand source code
async def get_starrail_announcements(
    self,
    *,
    uid: typing.Optional[int] = None,
    lang: typing.Optional[str] = None,
) -> typing.Sequence[models.Announcement]:
    """Get a list of Star Rail announcements."""
    if self.cookie_manager.multi:
        uid = uid or await self._get_uid(types.Game.STARRAIL)
    else:
        uid = uid or 809162009
    return await self._request_announcements(types.Game.STARRAIL, uid, lang=lang)

Get a list of Star Rail announcements.

async def get_web_events(self,
game: Game | None = None,
*,
size: int = 15,
offset: int | None = None,
lang: str | None = None) ‑> list[WebEvent]
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def get_web_events(
    self,
    game: typing.Optional[types.Game] = None,
    *,
    size: int = 15,
    offset: typing.Optional[int] = None,
    lang: typing.Optional[str] = None,
) -> list[models.WebEvent]:
    """Get a list of web events."""
    game = game or self.default_game
    if game is None:
        raise ValueError("No default game set.")

    data = await self.request_bbs(
        "community/community_contribution/wapi/event/list",
        params=dict(gids=constants.WEB_EVENT_GAME_IDS[game], size=size, offset=offset or ""),
        lang=lang,
    )
    return [models.WebEvent(**i) for i in data["list"]]

Get a list of web events.

async def get_zzz_announcements(self, *, uid: int | None = None, lang: str | None = None) ‑> Sequence[Announcement]
Expand source code
async def get_zzz_announcements(
    self,
    *,
    uid: typing.Optional[int] = None,
    lang: typing.Optional[str] = None,
) -> typing.Sequence[models.Announcement]:
    """Get a list of Zenless Zone Zero announcements."""
    if self.cookie_manager.multi:
        uid = uid or await self._get_uid(types.Game.ZZZ)
    else:
        uid = uid or 1300000000
    return await self._request_announcements(types.Game.ZZZ, uid, lang=lang)

Get a list of Zenless Zone Zero announcements.

async def join_topic(self, topic_id: int) ‑> None
Expand source code
async def join_topic(self, topic_id: int) -> None:
    """Join a topic."""
    await self._request_join(topic_id, is_cancel=False)

Join a topic.

async def leave_topic(self, topic_id: int) ‑> None
Expand source code
async def leave_topic(self, topic_id: int) -> None:
    """Leave a topic."""
    await self._request_join(topic_id, is_cancel=True)

Leave a topic.

async def redeem_code(self,
code: str,
uid: int | None = None,
*,
game: Game | None = None,
lang: str | None = None,
region: str | None = None) ‑> None
Expand source code
@managers.requires_cookie_token
async def redeem_code(
    self,
    code: str,
    uid: typing.Optional[int] = None,
    *,
    game: typing.Optional[types.Game] = None,
    lang: typing.Optional[str] = None,
    region: typing.Optional[str] = None,
) -> None:
    """Redeems a gift code for the current user."""
    if game is None:
        if self.default_game is None:
            raise RuntimeError("No default game set.")

        game = self.default_game

    if game not in constants.CODE_SUPPORTED_GAMES:
        raise ValueError(f"{game} does not support code redemption.")

    uid = uid or await self._get_uid(game)

    try:
        region = region or utility.recognize_server(uid, game)
    except Exception:
        warnings.warn(f"Failed to recognize server for game {game!r} and uid {uid!r}, fetching from API now.")
        region = await self._get_server_region(uid, game)

    post_request_games = {types.Game.ZZZ, types.Game.STARRAIL}
    game_origins = {
        types.Game.GENSHIN: "https://genshin.hoyoverse.com",
        types.Game.ZZZ: "https://zenless.hoyoverse.com",
        types.Game.STARRAIL: "https://hsr.hoyoverse.com",
        types.Game.TOT: "https://tot.hoyoverse.com",
    }
    headers = {
        "Origin": game_origins[game],
        "Referer": f"{game_origins[game]}/",
    }

    if game in post_request_games:
        data = dict(
            cdkey=code,
            device_uuid=str(uuid.uuid4()),
            game_biz=utility.get_prod_game_biz(self.region, game),
            lang=utility.create_short_lang_code(lang or self.lang),
            platform="4",
            region=region,
            t=int(time.time()),
            uid=uid,
        )
        await self.request(
            routes.CODE_URL.get_url(self.region, game),
            data=data,
            method="POST",
            headers=headers,
        )
    else:
        await self.request(
            routes.CODE_URL.get_url(self.region, game),
            params=dict(
                uid=uid,
                region=region,
                lang=utility.create_short_lang_code(lang or self.lang),
                cdkey=code,
                game_biz=utility.get_prod_game_biz(self.region, game),
            ),
            method="GET",
            headers=headers,
        )

Redeems a gift code for the current user.

async def reply_to_post(self, content: str, *, post_id: int) ‑> int
Expand source code
async def reply_to_post(self, content: str, *, post_id: int) -> int:
    """Reply to a community post."""
    data = await self.request_bbs(
        "community/post/wapi/releaseReply",
        data=dict(
            post_id=str(post_id),
            content=f"<p>{content}</p>",
            image_list=[],
            reply_bubble_id="",
            structured_content=json.dumps([{"insert": f"{content}\n"}]),
        ),
        method="POST",
        headers={"x-rpc-device_id": str(uuid.uuid4())},
    )
    return int(data["reply_id"])

Reply to a community post.

async def search_users(self, keyword: str, *, lang: str | None = None) ‑> Sequence[PartialHoyolabUser]
Expand source code
async def search_users(
    self,
    keyword: str,
    *,
    lang: typing.Optional[str] = None,
) -> typing.Sequence[models.PartialHoyolabUser]:
    """Search hoyolab users."""
    data = await self.request_bbs(
        "community/search/wapi/search/user",
        lang=lang,
        params=dict(keyword=keyword, page_size=20),
        cache=client_cache.cache_key("search", keyword=keyword, lang=self.lang),
    )
    return [models.PartialHoyolabUser(**i["user"]) for i in data["list"]]

Search hoyolab users.

async def set_accompany_voice_setting(self,
*,
game_id: int,
role_id: int,
voice_lang: str,
script_lang: str,
sync_all_roles: bool = False) ‑> None
Expand source code
@base.region_specific(types.Region.OVERSEAS)
async def set_accompany_voice_setting(
    self,
    *,
    game_id: int,
    role_id: int,
    voice_lang: str,
    script_lang: str,
    sync_all_roles: bool = False,
) -> None:
    """Set the voice and subtitle language setting of an accompany character."""
    await self._complete_stuid_cookie()
    setting = dict(
        voice_script_setting=dict(
            script_lang=script_lang,
            setting_unix=int(time.time() * 1000),
            voice_lang=voice_lang,
        )
    )
    await self.request_bbs(
        "community/user/api/role_setting/set",
        data=dict(
            game_id=game_id,
            role_id=role_id,
            setting=setting,
            sync_all_roles=sync_all_roles,
            types=["SettingTypeVoiceScript"],
        ),
        method="POST",
    )

Set the voice and subtitle language setting of an accompany character.

Inherited members