diff --git a/simnet/client/components/lab.py b/simnet/client/components/lab.py new file mode 100644 index 0000000..7930cc2 --- /dev/null +++ b/simnet/client/components/lab.py @@ -0,0 +1,210 @@ +import asyncio +from typing import Optional, List, Dict, Any + +from simnet.client.base import BaseClient +from simnet.client.headers import Headers +from simnet.client.routes import TAKUMI_URL, HK4E_URL, CODE_URL +from simnet.models.lab.announcement import Announcement +from simnet.models.lab.record import PartialUser, FullUser +from simnet.utils.enum_ import Region +from simnet.utils.lang import create_short_lang_code +from simnet.utils.player import recognize_genshin_server +from simnet.utils.types import HeaderTypes + +__all__ = ("LabClient",) + + +class LabClient(BaseClient): + """LabClient component.""" + + async def request_bbs( + self, + endpoint: str, + *, + lang: Optional[str] = None, + region: Optional[Region] = None, + method: Optional[str] = None, + params: Optional[Dict[str, Any]] = None, + data: Any = None, + headers: Optional[HeaderTypes] = None, + ) -> Dict[str, Any]: + """Makes a request to a bbs endpoint. + + Args: + endpoint (str): The URL of the endpoint to make the request to. + lang (str, optional): The language code used for the request. Defaults to None. + region (Region, optional): The server region used for the request. Defaults to None. + method (str, optional): The HTTP method used for the request. Defaults to None. + params (dict, optional): The parameters to include in the request. Defaults to None. + data (any, optional): The data to include in the request. Defaults to None. + headers (dict, optional): The headers to include in the request. Defaults to None. + + Returns: + dict: The response data from the request. + """ + headers = Headers(headers) + + lang = lang or self.lang + region = region or self.region + + url = TAKUMI_URL.get_url(region) / endpoint + + if self.region == Region.CHINESE: + headers["Referer"] = "https://www.miyoushe.com/" + + data = await self.request_lab( + url, + method=method, + params=params, + data=data, + headers=headers, + lang=lang, + new_ds=self.region == Region.CHINESE, + ) + return data + + async def search_users( + self, + keyword: str, + *, + lang: Optional[str] = None, + ) -> List[PartialUser]: + """Searches for users by keyword. + + Args: + keyword (str): The keyword to search for. + lang (str, optional): The language code used for the request. Defaults to None. + + Returns: + list of PartialUser: A list of partial user objects that match the search criteria. + """ + data = await self.request_bbs( + "community/search/wapi/search/user", + lang=lang, + params=dict(keyword=keyword, page_size=20), + ) + return [PartialUser(**i["user"]) for i in data["list"]] + + async def get_user_info( + self, + accident: Optional[int] = None, + *, + lang: Optional[str] = None, + ) -> FullUser: + """Gets user information for the current or specified user. + + Args: + accident (int, optional): The user ID to get information for. Defaults to None. + lang (str, optional): The language code used for the request. Defaults to None. + + Returns: + FullUser: A full user object for the specified user. + """ + if self.region == Region.OVERSEAS: + url = "/community/painter/wapi/user/full" + elif self.region == Region.CHINESE: + url = "/user/wapi/getUserFullInfo" + else: + raise TypeError(f"{self.region!r} is not a valid region.") + + data = await self.request_bbs( + endpoint=url, + lang=lang, + params=dict(uid=accident) if accident else None, + ) + return FullUser(**data["user_info"]) + + async def get_recommended_users(self, *, limit: int = 200) -> List[PartialUser]: + """Gets a list of recommended active users. + + Args: + limit (int, optional): The maximum number of users to retrieve. Defaults to 200. + + Returns: + list of PartialUser: A list of partial user objects for recommended active users. + """ + data = await self.request_bbs( + "community/user/wapi/recommendActive", + params=dict(page_size=limit), + ) + return [PartialUser(**i["user"]) for i in data["list"]] + + async def get_genshin_announcements( + self, + player_id: Optional[str] = None, + *, + lang: Optional[str] = None, + ) -> List[Announcement]: + """Gets a list of Genshin Impact game announcements. + + Args: + player_id (str, optional): The player ID to get announcements for. Defaults to None. + lang (str, optional): The language code used for the request. Defaults to None. + + Returns: + list of Announcement: A list of announcement objects for the specified player. + """ + player_id = self.player_id or player_id + if player_id is None: + player_id = 900000005 + + params = dict( + game="hk4e", + game_biz="hk4e_global", + bundle_id="hk4e_global", + platform="pc", + region=recognize_genshin_server(player_id), + uid=player_id, + level=8, + lang=lang or self.lang, + ) + + info, details = await asyncio.gather( + self.request_bbs( + HK4E_URL.get_url() / "announcement/api/getAnnList", + lang=lang, + params=params, + ), + self.request_bbs( + HK4E_URL.get_url() / "announcement/api/getAnnContent", + lang=lang, + params=params, + ), + ) + + announcements: List[Dict[str, Any]] = [] + for sublist in info["list"]: + for info in sublist["list"]: + detail = next( + (i for i in details["list"] if i["ann_id"] == info["ann_id"]), None + ) + announcements.append({**info, **(detail or {})}) + + return [Announcement(**i) for i in announcements] + + async def redeem_code( + self, + code: str, + player_id: Optional[int] = None, + *, + lang: Optional[str] = None, + ) -> None: + """Redeems a gift code for the current or specified user. + + Args: + code (str): The gift code to redeem. + player_id (int, optional): The player ID to redeem the code for. Defaults to None. + lang (str, optional): The language code used for the request. Defaults to None. + """ + player_id = self.player_id or player_id + + await self.request_bbs( + CODE_URL.get_url(), + params=dict( + uid=player_id, + region=recognize_genshin_server(player_id), + cdkey=code, + game_biz="hk4e_global", + lang=create_short_lang_code(lang or self.lang), + ), + ) diff --git a/simnet/client/genshin.py b/simnet/client/genshin.py index 804fadd..7466171 100644 --- a/simnet/client/genshin.py +++ b/simnet/client/genshin.py @@ -3,6 +3,7 @@ from typing import Optional from simnet.client.components.auth import AuthClient from simnet.client.components.chronicle.genshin import GenshinBattleChronicleClient from simnet.client.components.daily import DailyRewardClient +from simnet.client.components.lab import LabClient from simnet.client.components.wish.genshin import GenshinWishClient from simnet.utils.enum_ import Game @@ -10,7 +11,11 @@ __all__ = ("GenshinClient",) class GenshinClient( - GenshinBattleChronicleClient, GenshinWishClient, AuthClient, DailyRewardClient + GenshinBattleChronicleClient, + GenshinWishClient, + AuthClient, + DailyRewardClient, + LabClient, ): """A simple http client for StarRail endpoints.""" diff --git a/simnet/client/genshin.pyi b/simnet/client/genshin.pyi index fdaa978..fc64423 100644 --- a/simnet/client/genshin.pyi +++ b/simnet/client/genshin.pyi @@ -3,12 +3,13 @@ from typing import Optional from simnet.client.components.auth import AuthClient from simnet.client.components.chronicle.genshin import GenshinBattleChronicleClient from simnet.client.components.daily import DailyRewardClient +from simnet.client.components.lab import LabClient from simnet.client.components.wish.genshin import GenshinWishClient from simnet.utils.enum_ import Region from simnet.utils.types import CookieTypes, HeaderTypes, TimeoutTypes -class GenshinClient(GenshinBattleChronicleClient, GenshinWishClient, AuthClient, DailyRewardClient): +class GenshinClient(GenshinBattleChronicleClient, GenshinWishClient, AuthClient, DailyRewardClient, LabClient): def __init__( self, cookies: Optional[CookieTypes] = None, diff --git a/simnet/client/routes.py b/simnet/client/routes.py index a3963b7..eb08902 100644 --- a/simnet/client/routes.py +++ b/simnet/client/routes.py @@ -21,6 +21,10 @@ __all__ = ( "AUTH_KEY_URL", "HK4E_LOGIN_URL", "REWARD_URL", + "TAKUMI_URL", + "CALCULATOR_URL", + "HK4E_URL", + "CODE_URL", ) @@ -227,3 +231,20 @@ REWARD_URL = GameRoute( hkrpg="https://api-takumi.mihoyo.com/event/luna/?act_id=e202304121516551", ), ) +TAKUMI_URL = InternationalRoute( + overseas="https://api-os-takumi.mihoyo.com/", + chinese="https://api-takumi.mihoyo.com/", +) + +CALCULATOR_URL = InternationalRoute( + overseas="https://sg-public-api.hoyoverse.com/event/calculateos/", + chinese="https://api-takumi.mihoyo.com/event/e20200928calculate/v1/", +) + + +HK4E_URL = Route("https://sg-hk4e-api.hoyoverse.com/common/hk4e_global/") + + +CODE_URL = Route( + "https://sg-hk4e-api.hoyoverse.com/common/apicdkey/api/webExchangeCdkey" +) diff --git a/simnet/client/starrail.py b/simnet/client/starrail.py index 1f48bc6..6d42995 100644 --- a/simnet/client/starrail.py +++ b/simnet/client/starrail.py @@ -2,13 +2,21 @@ from typing import Optional from simnet.client.components.auth import AuthClient from simnet.client.components.chronicle.starrail import StarRailBattleChronicleClient +from simnet.client.components.daily import DailyRewardClient +from simnet.client.components.lab import LabClient from simnet.client.components.wish.starrail import StarRailWishClient from simnet.utils.enum_ import Game __all__ = ("StarRailClient",) -class StarRailClient(StarRailBattleChronicleClient, StarRailWishClient, AuthClient): +class StarRailClient( + StarRailBattleChronicleClient, + StarRailWishClient, + DailyRewardClient, + AuthClient, + LabClient, +): """A simple http client for StarRail endpoints.""" game: Optional[Game] = Game.STARRAIL diff --git a/simnet/client/starrail.pyi b/simnet/client/starrail.pyi index 3b02007..3f73ae8 100644 --- a/simnet/client/starrail.pyi +++ b/simnet/client/starrail.pyi @@ -2,11 +2,14 @@ from typing import Optional from simnet.client.components.auth import AuthClient from simnet.client.components.chronicle.starrail import StarRailBattleChronicleClient +from simnet.client.components.daily import DailyRewardClient +from simnet.client.components.lab import LabClient from simnet.client.components.wish.starrail import StarRailWishClient from simnet.utils.enum_ import Region from simnet.utils.types import CookieTypes, HeaderTypes, TimeoutTypes -class StarRailClient(StarRailBattleChronicleClient, StarRailWishClient, AuthClient): + +class StarRailClient(StarRailBattleChronicleClient, StarRailWishClient,DailyRewardClient, AuthClient, LabClient): def __init__( self, cookies: Optional[CookieTypes] = None, diff --git a/simnet/models/lab/announcement.py b/simnet/models/lab/announcement.py new file mode 100644 index 0000000..4cf8583 --- /dev/null +++ b/simnet/models/lab/announcement.py @@ -0,0 +1,62 @@ +from datetime import datetime + +from pydantic import Field + +from simnet.models.base import APIModel + +__all__ = ("Announcement",) + + +class Announcement(APIModel): + """ + Represents an announcement. + + Attributes: + id (int): The ID of the announcement. + title (str): The title of the announcement. + subtitle (str): The subtitle of the announcement. + banner (str): The URL of the banner image for the announcement. + content (str): The content of the announcement. + + type_label (str): The label of the announcement type. + type (int): The type of the announcement. + tag_icon (str): The URL of the tag icon for the announcement. + + login_alert (bool): Indicates whether the announcement is shown to logged-in users only. + remind (bool): Indicates whether to send reminder notifications for the announcement. + alert (bool): Indicates whether to send alert notifications for the announcement. + remind_ver (int): The version of the reminder notification. + extra_remind (bool): Indicates whether to send additional reminder notifications for the announcement. + + start_time (datetime): The start time of the announcement. + end_time (datetime): The end time of the announcement. + tag_start_time (datetime): The start time of the tag for the announcement. + tag_end_time (datetime): The end time of the tag for the announcement. + + lang (str): The language of the announcement. + has_content (bool): Indicates whether the announcement has content. + """ + + id: int = Field(alias="ann_id") + title: str + subtitle: str + banner: str + content: str + + type_label: str + type: int + tag_icon: str + + login_alert: bool + remind: bool + alert: bool + remind_ver: int + extra_remind: bool + + start_time: datetime + end_time: datetime + tag_start_time: datetime + tag_end_time: datetime + + lang: str + has_content: bool diff --git a/tests/test_lab_client.py b/tests/test_lab_client.py new file mode 100644 index 0000000..9e61cc5 --- /dev/null +++ b/tests/test_lab_client.py @@ -0,0 +1,29 @@ +from typing import TYPE_CHECKING + +import pytest +import pytest_asyncio + +from simnet.client.components.lab import LabClient +from simnet.utils.enum_ import Region + +if TYPE_CHECKING: + from simnet.client.cookies import Cookies + + +@pytest_asyncio.fixture +async def client_instance(account_id: int, cookies: "Cookies"): + async with LabClient( + cookies=cookies, + account_id=account_id, + region=Region.CHINESE, + ) as client_instance: + yield client_instance + + +@pytest.mark.asyncio +class TestStarRailClient: + @staticmethod + async def test_get_user_info(account_id: int, client_instance: "LabClient"): + user_info = await client_instance.get_user_info() + assert user_info.nickname + assert user_info.accident_id == account_id