From 9d0277b805b18ee4fbd9a2c364348c8708e708ce Mon Sep 17 00:00:00 2001 From: seria Date: Mon, 1 Jul 2024 09:26:51 +0900 Subject: [PATCH 1/9] fix: Reset socks proxy when set proxy to None --- genshin/client/manager/managers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/genshin/client/manager/managers.py b/genshin/client/manager/managers.py index 2b3b2b93..9f7c1004 100644 --- a/genshin/client/manager/managers.py +++ b/genshin/client/manager/managers.py @@ -113,6 +113,7 @@ def proxy(self) -> typing.Optional[yarl.URL]: def proxy(self, proxy: typing.Optional[aiohttp.typedefs.StrOrURL]) -> None: if proxy is None: self._proxy = None + self._socks_proxy = None return proxy = yarl.URL(proxy) From dd06c8bdee457f629c8573b57d65248854d05609 Mon Sep 17 00:00:00 2001 From: seria Date: Tue, 2 Jul 2024 00:25:00 +0900 Subject: [PATCH 2/9] Support Imaginarium Theater (#198) * Add models and method * Add test * Continue to use Aliased field cuz it magically works again * Add medal_num field Co-authored-by: omg-xtao <100690902+omg-xtao@users.noreply.github.com> * Rename is_enhanced field * Improve implementation * Add need_detail param --------- Co-authored-by: omg-xtao <100690902+omg-xtao@users.noreply.github.com> --- .../client/components/chronicle/genshin.py | 17 ++ genshin/models/genshin/chronicle/__init__.py | 1 + .../models/genshin/chronicle/img_theater.py | 152 ++++++++++++++++++ .../components/test_genshin_chronicle.py | 6 + 4 files changed, 176 insertions(+) create mode 100644 genshin/models/genshin/chronicle/img_theater.py diff --git a/genshin/client/components/chronicle/genshin.py b/genshin/client/components/chronicle/genshin.py index 2f88b698..d89d60eb 100644 --- a/genshin/client/components/chronicle/genshin.py +++ b/genshin/client/components/chronicle/genshin.py @@ -107,6 +107,23 @@ async def get_genshin_spiral_abyss( return models.SpiralAbyss(**data) + async def get_imaginarium_theater( + self, + uid: int, + *, + previous: bool = False, + need_detail: bool = True, + lang: typing.Optional[str] = None, + ) -> models.ImgTheater: + """Get Genshin Impact imaginarium theater runs.""" + payload = { + "schedule_type": 2 if previous else 1, # There's 1 season for now but I assume it works like this + "need_detail": str(need_detail).lower(), + } + data = await self._request_genshin_record("role_combat", uid, lang=lang, payload=payload) + + return models.ImgTheater(**data) + async def get_genshin_notes( self, uid: typing.Optional[int] = None, diff --git a/genshin/models/genshin/chronicle/__init__.py b/genshin/models/genshin/chronicle/__init__.py index 094b8f71..842045c0 100644 --- a/genshin/models/genshin/chronicle/__init__.py +++ b/genshin/models/genshin/chronicle/__init__.py @@ -3,6 +3,7 @@ from .abyss import * from .activities import * from .characters import * +from .img_theater import * from .notes import * from .stats import * from .tcg import * diff --git a/genshin/models/genshin/chronicle/img_theater.py b/genshin/models/genshin/chronicle/img_theater.py new file mode 100644 index 00000000..cb732799 --- /dev/null +++ b/genshin/models/genshin/chronicle/img_theater.py @@ -0,0 +1,152 @@ +import datetime +import enum +import typing + +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +from genshin.constants import CN_TIMEZONE +from genshin.models.genshin import character +from genshin.models.model import Aliased, APIModel + +__all__ = ( + "Act", + "ActCharacter", + "ImgTheater", + "TheaterBuff", + "TheaterCharaType", + "TheaterDifficulty", + "TheaterSchedule", + "TheaterStats", +) + + +class TheaterCharaType(enum.IntEnum): + """The type of character in the context of the imaginarium theater gamemode.""" + + NORMAL = 1 + TRIAL = 2 + SUPPORT = 3 + + +class TheaterDifficulty(enum.IntEnum): + """The difficulty of the imaginarium theater data.""" + + EASY = 1 + NORMAL = 2 + HARD = 3 + + +class ActCharacter(character.BaseCharacter): + """A character in an act.""" + + type: TheaterCharaType = Aliased("avatar_type") + level: int + + +class TheaterBuff(APIModel): + """Represents either a 'mystery cache' or a 'wondrous boom'.""" + + icon: str + name: str + description: str = Aliased("desc") + received_audience_support: bool = Aliased("is_enhanced") + """Whether external audience support is received.""" + id: int + + +class Act(APIModel): + """One act in the theater.""" + + characters: typing.Sequence[ActCharacter] = Aliased("avatars") + mystery_caches: typing.Sequence[TheaterBuff] = Aliased("choice_cards") + wondroud_booms: typing.Sequence[TheaterBuff] = Aliased("buffs") + medal_obtained: bool = Aliased("is_get_medal") + round_id: int + finish_time: int # As timestamp + finish_datetime: datetime.datetime = Aliased("finish_date_time") + + @pydantic.validator("finish_datetime", pre=True) + def __parse_datetime(cls, value: typing.Mapping[str, typing.Any]) -> datetime.datetime: + return datetime.datetime( + year=value["year"], + month=value["month"], + day=value["day"], + hour=value["hour"], + minute=value["minute"], + second=value["second"], + tzinfo=CN_TIMEZONE, + ) + + +class TheaterStats(APIModel): + """Imaginarium theater stats.""" + + difficulty: TheaterDifficulty = Aliased("difficulty_id") + best_record: int = Aliased("max_round_id") + """The maximum act the player has reached.""" + heraldry: int # Not sure what this is + star_challenge_stellas: typing.Sequence[bool] = Aliased("get_medal_round_list") + """Whether the player has obtained the medal for each act.""" + fantasia_flowers_used: int = Aliased("coin_num") + """The number of Fantasia Flowers used.""" + audience_support_trigger_num: int = Aliased("avatar_bonus_num") + """The number of external audience support triggers.""" + player_assists: int = Aliased("rent_cnt") + """The number of supporting cast characters assisting other players.""" + medal_num: int + """The number of medals the player has obtained.""" + + +class TheaterSchedule(APIModel): + """Imaginarium theater schedule.""" + + start_time: int # As timestamp + end_time: int # As timestamp + schedule_type: int # Not sure what this is + id: int = Aliased("schedule_id") + start_datetime: datetime.datetime = Aliased("start_date_time") + end_datetime: datetime.datetime = Aliased("end_date_time") + + @pydantic.validator("start_datetime", "end_datetime", pre=True) + def __parse_datetime(cls, value: typing.Mapping[str, typing.Any]) -> datetime.datetime: + return datetime.datetime( + year=value["year"], + month=value["month"], + day=value["day"], + hour=value["hour"], + minute=value["minute"], + second=value["second"], + tzinfo=CN_TIMEZONE, + ) + + +class ImgTheaterData(APIModel): + """Imaginarium theater data.""" + + acts: typing.Sequence[Act] = Aliased(alias="rounds_data") + backup_characters: typing.Sequence[ActCharacter] = Aliased(alias="backup_avatars") # Not sure what this is + stats: TheaterStats = Aliased(alias="stat") + schedule: TheaterSchedule + has_data: bool + has_detail_data: bool + + @pydantic.root_validator(pre=True) + def __unnest_detail(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + detail: typing.Optional[typing.Dict[str, typing.Any]] = values.get("detail") + has_detail = detail is not None + values["rounds_data"] = detail.get("rounds_data", []) if has_detail else [] + values["backup_avatars"] = detail.get("backup_avatars", []) if has_detail else [] + return values + + +class ImgTheater(APIModel): + """Imaginarium theater.""" + + datas: typing.Sequence[ImgTheaterData] = Aliased("data") + unlocked: bool = Aliased("is_unlock") diff --git a/tests/client/components/test_genshin_chronicle.py b/tests/client/components/test_genshin_chronicle.py index ce4daff3..81c667ee 100644 --- a/tests/client/components/test_genshin_chronicle.py +++ b/tests/client/components/test_genshin_chronicle.py @@ -29,6 +29,12 @@ async def test_spiral_abyss(client: genshin.Client, genshin_uid: int): assert data +async def test_imaginarium_theater(client: genshin.Client, genshin_uid: int): + data = await client.get_imaginarium_theater(genshin_uid) + + assert data + + async def test_notes(lclient: genshin.Client, genshin_uid: int): data = await lclient.get_notes(genshin_uid) From 21ccb3ce6fbc0fa4807e917254b6929c763830ee Mon Sep 17 00:00:00 2001 From: seria Date: Fri, 5 Jul 2024 10:49:14 +0900 Subject: [PATCH 3/9] Add Partial ZZZ Support (#200) * Add zzz code redeem route * Modify game check logic * Add zzz server recognition function * Add daily reward URLs * Remove unreachable code * Add real-time notes support * Apply reformat and export models to dunder all * Add fetching user stats * Fix code redemption not working * Sort dunder all * Add buddy_list --- genshin/client/components/chronicle/base.py | 10 ++- genshin/client/components/chronicle/client.py | 3 +- genshin/client/components/chronicle/zzz.py | 89 +++++++++++++++++++ genshin/client/components/hoyolab.py | 2 +- genshin/client/routes.py | 8 ++ genshin/models/__init__.py | 1 + genshin/models/zzz/__init__.py | 4 + genshin/models/zzz/character.py | 57 ++++++++++++ genshin/models/zzz/chronicle/__init__.py | 4 + genshin/models/zzz/chronicle/notes.py | 72 +++++++++++++++ genshin/models/zzz/chronicle/stats.py | 40 +++++++++ genshin/utility/uid.py | 50 ++++++++--- 12 files changed, 324 insertions(+), 16 deletions(-) create mode 100644 genshin/client/components/chronicle/zzz.py create mode 100644 genshin/models/zzz/__init__.py create mode 100644 genshin/models/zzz/character.py create mode 100644 genshin/models/zzz/chronicle/__init__.py create mode 100644 genshin/models/zzz/chronicle/notes.py create mode 100644 genshin/models/zzz/chronicle/stats.py diff --git a/genshin/client/components/chronicle/base.py b/genshin/client/components/chronicle/base.py index 7a92bc64..4e3ba856 100644 --- a/genshin/client/components/chronicle/base.py +++ b/genshin/client/components/chronicle/base.py @@ -47,10 +47,12 @@ async def request_game_record( **kwargs: typing.Any, ) -> typing.Mapping[str, typing.Any]: """Make a request towards the game record endpoint.""" - base_url = routes.RECORD_URL.get_url(region or self.region) - - if game: - base_url = base_url / game.value / "api" + if game is types.Game.ZZZ: + base_url = routes.ZZZ_RECORD_URL.get_url(region or self.region) + else: + base_url = routes.RECORD_URL.get_url(region or self.region) + if game is not None: + base_url = base_url / game.value / "api" url = base_url / endpoint diff --git a/genshin/client/components/chronicle/client.py b/genshin/client/components/chronicle/client.py index 94f31407..eeaf336c 100644 --- a/genshin/client/components/chronicle/client.py +++ b/genshin/client/components/chronicle/client.py @@ -1,6 +1,6 @@ """Battle chronicle component.""" -from . import genshin, honkai, starrail +from . import genshin, honkai, starrail, zzz __all__ = ["BattleChronicleClient"] @@ -9,5 +9,6 @@ class BattleChronicleClient( genshin.GenshinBattleChronicleClient, honkai.HonkaiBattleChronicleClient, starrail.StarRailBattleChronicleClient, + zzz.ZZZBattleChronicleClient, ): """Battle chronicle component.""" diff --git a/genshin/client/components/chronicle/zzz.py b/genshin/client/components/chronicle/zzz.py new file mode 100644 index 00000000..8d3fcc8b --- /dev/null +++ b/genshin/client/components/chronicle/zzz.py @@ -0,0 +1,89 @@ +"""StarRail battle chronicle component.""" + +import typing + +from genshin import errors, types, utility +from genshin.models.zzz import chronicle as models + +from . import base + +__all__ = ("ZZZBattleChronicleClient",) + + +class ZZZBattleChronicleClient(base.BaseBattleChronicleClient): + """ZZZ battle chronicle component.""" + + async def _request_zzz_record( + self, + endpoint: str, + uid: typing.Optional[int] = None, + *, + method: str = "GET", + lang: typing.Optional[str] = None, + payload: typing.Optional[typing.Mapping[str, typing.Any]] = None, + cache: bool = True, + ) -> typing.Mapping[str, typing.Any]: + """Get an arbitrary ZZZ object.""" + payload = dict(payload or {}) + original_payload = payload.copy() + + uid = uid or await self._get_uid(types.Game.ZZZ) + payload = dict(role_id=uid, server=utility.recognize_zzz_server(uid), **payload) + + data, params = None, None + if method == "POST": + data = payload + else: + params = payload + + cache_key: typing.Optional[base.ChronicleCacheKey] = None + if cache: + cache_key = base.ChronicleCacheKey( + types.Game.ZZZ, + endpoint, + uid, + lang=lang or self.lang, + params=tuple(original_payload.values()), + ) + + return await self.request_game_record( + endpoint, + lang=lang, + game=types.Game.ZZZ, + region=utility.recognize_region(uid, game=types.Game.ZZZ), + params=params, + data=data, + cache=cache_key, + ) + + async def get_zzz_notes( + self, + uid: typing.Optional[int] = None, + *, + lang: typing.Optional[str] = None, + autoauth: bool = True, + ) -> models.ZZZNotes: + """Get ZZZ sticky notes (real-time notes).""" + try: + data = await self._request_zzz_record("note", uid, lang=lang, cache=False) + except errors.DataNotPublic as e: + # error raised only when real-time notes are not enabled + if uid and (await self._get_uid(types.Game.ZZZ)) != uid: + raise errors.GenshinException(e.response, "Cannot view real-time notes of other users.") from e + if not autoauth: + raise errors.GenshinException(e.response, "Real-time notes are not enabled.") from e + + await self.update_settings(3, True, game=types.Game.ZZZ) + data = await self._request_zzz_record("note", uid, lang=lang, cache=False) + + return models.ZZZNotes(**data) + + async def get_zzz_user( + self, + uid: typing.Optional[int] = None, + *, + lang: typing.Optional[str] = None, + ) -> models.ZZZUserStats: + """Get starrail user.""" + data = await self._request_zzz_record("index", uid, lang=lang, cache=False) + return models.ZZZUserStats(**data) diff --git a/genshin/client/components/hoyolab.py b/genshin/client/components/hoyolab.py index 7e1804dc..93f6f104 100644 --- a/genshin/client/components/hoyolab.py +++ b/genshin/client/components/hoyolab.py @@ -124,7 +124,7 @@ async def redeem_code( game = self.default_game - if not (game == types.Game.GENSHIN or game == types.Game.STARRAIL): + if game not in {types.Game.GENSHIN, types.Game.ZZZ, types.Game.STARRAIL}: raise ValueError(f"{game} does not support code redemption.") uid = uid or await self._get_uid(game) diff --git a/genshin/client/routes.py b/genshin/client/routes.py index 1288c067..7ef6420f 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -37,6 +37,7 @@ "WEBSTATIC_URL", "WEB_LOGIN_URL", "YSULOG_URL", + "ZZZ_RECORD_URL", "Route", ] @@ -138,6 +139,10 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: overseas="https://bbs-api-os.hoyolab.com/game_record/", chinese="https://api-takumi-record.mihoyo.com/game_record/app/", ) +ZZZ_RECORD_URL = InternationalRoute( + overseas="https://sg-act-nap-api.hoyolab.com/event/game_record_zzz/api/zzz/", + chinese="https://api-takumi-record.mihoyo.com/event/game_record_zzz/api/zzz/", +) LINEUP_URL = InternationalRoute( overseas="https://sg-public-api.hoyoverse.com/event/simulatoros/", chinese="https://api-takumi.mihoyo.com/event/platsimulator/", @@ -184,11 +189,13 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: genshin="https://sg-hk4e-api.hoyolab.com/event/sol?act_id=e202102251931481", honkai3rd="https://sg-public-api.hoyolab.com/event/mani?act_id=e202110291205111", hkrpg="https://sg-public-api.hoyolab.com/event/luna/os?act_id=e202303301540311", + nap="https://sg-act-nap-api.hoyolab.com/event/luna/zzz/os?act_id=e202406031448091", ), chinese=dict( genshin="https://api-takumi.mihoyo.com/event/luna/?act_id=e202311201442471", honkai3rd="https://api-takumi.mihoyo.com/event/luna/?act_id=e202306201626331", hkrpg="https://api-takumi.mihoyo.com/event/luna/?act_id=e202304121516551", + nap="https://act-nap-api.mihoyo.com/event/luna/zzz/?act_id=e202406242138391", ), ) @@ -196,6 +203,7 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: overseas=dict( genshin="https://sg-hk4e-api.hoyoverse.com/common/apicdkey/api/webExchangeCdkey", hkrpg="https://sg-hkrpg-api.hoyoverse.com/common/apicdkey/api/webExchangeCdkey", + nap="https://public-operation-nap.hoyoverse.com/common/apicdkey/api/webExchangeCdkey", ), chinese=dict(), ) diff --git a/genshin/models/__init__.py b/genshin/models/__init__.py index b515d70e..0b175760 100644 --- a/genshin/models/__init__.py +++ b/genshin/models/__init__.py @@ -6,3 +6,4 @@ from .hoyolab import * from .model import * from .starrail import * +from .zzz import * diff --git a/genshin/models/zzz/__init__.py b/genshin/models/zzz/__init__.py new file mode 100644 index 00000000..0f1e6308 --- /dev/null +++ b/genshin/models/zzz/__init__.py @@ -0,0 +1,4 @@ +"""Zenless Zone Zero models.""" + +from .character import * +from .chronicle import * diff --git a/genshin/models/zzz/character.py b/genshin/models/zzz/character.py new file mode 100644 index 00000000..b308b922 --- /dev/null +++ b/genshin/models/zzz/character.py @@ -0,0 +1,57 @@ +import enum +import typing + +from genshin.models.model import Aliased, APIModel, Unique + +__all__ = ( + "ZZZBaseAgent", + "ZZZElementType", + "ZZZPartialAgent", + "ZZZSpeciality", +) + + +class ZZZElementType(enum.IntEnum): + """ZZZ element type.""" + + PHYSICAL = 200 + FIRE = 201 + ICE = 202 + ELECTRIC = 203 + ETHER = 205 + + +class ZZZSpeciality(enum.IntEnum): + """ZZZ agent compatible speciality.""" + + ATTACK = 1 + STUN = 2 + ANOMALY = 3 + SUPPORT = 4 + DEFENSE = 5 + + +class ZZZBaseAgent(APIModel, Unique): + """ZZZ base agent model.""" + + id: int # 4 digit number + element: ZZZElementType = Aliased("element_type") + rarity: typing.Literal["S", "A"] + name: str = Aliased("name_mi18n") + speciality: ZZZSpeciality = Aliased("avatar_profession") + faction_icon: str = Aliased("group_icon_path") + flat_icon: str = Aliased("hollow_icon_path") + + @property + def icon(self) -> str: + return ( + f"https://act-webstatic.hoyoverse.com/game_record/zzz/role_square_avatar/role_square_avatar_{self.id}.png" + ) + + +class ZZZPartialAgent(ZZZBaseAgent): + """Character without any equipment.""" + + level: int + rank: int + """Also known as Mindscape Cinema in-game.""" diff --git a/genshin/models/zzz/chronicle/__init__.py b/genshin/models/zzz/chronicle/__init__.py new file mode 100644 index 00000000..4f38b35d --- /dev/null +++ b/genshin/models/zzz/chronicle/__init__.py @@ -0,0 +1,4 @@ +"""ZZZ chronicle models.""" + +from .notes import * +from .stats import * diff --git a/genshin/models/zzz/chronicle/notes.py b/genshin/models/zzz/chronicle/notes.py new file mode 100644 index 00000000..52e00715 --- /dev/null +++ b/genshin/models/zzz/chronicle/notes.py @@ -0,0 +1,72 @@ +"""ZZZ sticky notes (real-time notes) models.""" + +import datetime +import enum +import typing + +from genshin.models.model import Aliased, APIModel + +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +__all__ = ("BatteryCharge", "VideoStoreState", "ZZZEngagement", "ZZZNotes") + + +class VideoStoreState(enum.Enum): + """Video store management state.""" + + REVENUE_AVAILABLE = "SaleStateDone" + WAITING_TO_OPEN = "SaleStateNo" + CURRENTLY_OPEN = "SaleStateDoing" + + +class BatteryCharge(APIModel): + """ZZZ battery charge model.""" + + current: int + max: int + seconds_till_full: int = Aliased("restore") + + @property + def is_full(self) -> bool: + """Check if the energy is full.""" + return self.current == self.max + + @property + def full_datetime(self) -> datetime.datetime: + """Get the datetime when the energy will be full.""" + return datetime.datetime.now().astimezone() + datetime.timedelta(seconds=self.seconds_till_full) + + @pydantic.root_validator(pre=True) + def __unnest_progress(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + return {**values, **values.pop("progress")} + + +class ZZZEngagement(APIModel): + """ZZZ engagement model.""" + + current: int + max: int + + +class ZZZNotes(APIModel): + """Zenless Zone Zero sticky notes model.""" + + battery_charge: BatteryCharge = Aliased("energy") + engagement: ZZZEngagement = Aliased("vitality") + scratch_card_completed: bool = Aliased("card_sign") + video_store_state: VideoStoreState + + @pydantic.validator("scratch_card_completed", pre=True) + def __transform_value(cls, v: typing.Literal["CardSignDone", "CardSignNotDone"]) -> bool: + return v == "CardSignDone" + + @pydantic.root_validator(pre=True) + def __unnest_value(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + values["video_store_state"] = values["vhs_sale"]["sale_state"] + return values diff --git a/genshin/models/zzz/chronicle/stats.py b/genshin/models/zzz/chronicle/stats.py new file mode 100644 index 00000000..348c2001 --- /dev/null +++ b/genshin/models/zzz/chronicle/stats.py @@ -0,0 +1,40 @@ +"""ZZZ data overview models.""" + +import typing + +from genshin.models.model import Aliased, APIModel + +from ..character import ZZZPartialAgent + +__all__ = ( + "ZZZStats", + "ZZZUserStats", +) + + +class ZZZStats(APIModel): + """ZZZ data overview stats.""" + + active_days: int + character_num: int = Aliased("avatar_num") + inter_knot_reputation: str = Aliased("world_level_name") + shiyu_defense_frontiers: int = Aliased("cur_period_zone_layer_count") + bangboo_obtained: int = Aliased("buddy_num") + + +class ZZZBaseBangboo(APIModel): + """Base bangboo (buddy) model.""" + + id: int + name: str + rarity: typing.Literal["S", "A"] + level: int + star: int + + +class ZZZUserStats(APIModel): + """Zenless Zone Zero user model.""" + + stats: ZZZStats + agents: typing.Sequence[ZZZPartialAgent] = Aliased("avatar_list") + bangboos: typing.Sequence[ZZZBaseBangboo] = Aliased("buddy_list") diff --git a/genshin/utility/uid.py b/genshin/utility/uid.py index 64e4b668..5a515b7e 100644 --- a/genshin/utility/uid.py +++ b/genshin/utility/uid.py @@ -13,6 +13,7 @@ "recognize_region", "recognize_server", "recognize_starrail_server", + "recognize_zzz_server", ] UID_RANGE: typing.Mapping[types.Game, typing.Mapping[types.Region, typing.Sequence[str]]] = { @@ -51,6 +52,14 @@ } """Mapping of Star Rail servers to their respective UID ranges.""" +ZZZ_SERVER_RANGE: typing.Mapping[str, typing.Sequence[str]] = { + "prod_gf_us": ("10",), + "prod_gf_eu": ("15",), + "prod_gf_jp": ("13",), + "prod_gf_sg": ("17",), +} +"""Mapping of global Zenless Zone Zero servers to their respective UID ranges.""" + def create_short_lang_code(lang: str) -> str: """Create an alternative short lang code.""" @@ -69,16 +78,18 @@ def recognize_genshin_server(uid: int) -> str: def get_prod_game_biz(region: types.Region, game: types.Game) -> str: """Get the game_biz value corresponding to a game and region.""" game_biz = "" - if game == types.Game.HONKAI: + if game is types.Game.HONKAI: game_biz = "bh3_" - elif game == types.Game.GENSHIN: + elif game is types.Game.GENSHIN: game_biz = "hk4e_" - elif game == types.Game.STARRAIL: + elif game is types.Game.STARRAIL: game_biz = "hkrpg_" + elif game is types.Game.ZZZ: + game_biz = "nap_" - if region == types.Region.OVERSEAS: + if region is types.Region.OVERSEAS: game_biz += "global" - elif region == types.Region.CHINESE: + elif region is types.Region.CHINESE: game_biz += "cn" return game_biz @@ -109,16 +120,30 @@ def recognize_starrail_server(uid: int) -> str: raise ValueError(f"UID {uid} isn't associated with any server") +def recognize_zzz_server(uid: int) -> str: + """Recognize which server a Zenless Zone Zero UID is from.""" + # CN region UIDs only has 8 digits, global has 10, so we use this method to recognize the server + # This might change in the future when UIDs run out but... let's keep it like this for now + if len(str(uid)) == 8: + return "prod_gf_cn" + + for server, digits in ZZZ_SERVER_RANGE.items(): + if str(uid)[:-8] in digits: + return server + + raise ValueError(f"UID {uid} isn't associated with any server") + + def recognize_server(uid: int, game: types.Game) -> str: """Recognizes which server a UID is from.""" - if game == types.Game.HONKAI: + if game is types.Game.HONKAI: return recognize_honkai_server(uid) - elif game == types.Game.GENSHIN: + if game is types.Game.GENSHIN: return recognize_genshin_server(uid) - elif game == types.Game.STARRAIL: + if game is types.Game.STARRAIL: return recognize_starrail_server(uid) - else: - raise ValueError(f"{game} is not a valid game") + if game is types.Game.ZZZ: + return recognize_zzz_server(uid) def recognize_game(uid: int, region: types.Region) -> typing.Optional[types.Game]: @@ -135,6 +160,11 @@ def recognize_game(uid: int, region: types.Region) -> typing.Optional[types.Game def recognize_region(uid: int, game: types.Game) -> typing.Optional[types.Region]: """Recognize the region of a uid.""" + if game is types.Game.ZZZ: + if len(str(uid)) == 8: + return types.Region.CHINESE + return types.Region.OVERSEAS + for region, digits in UID_RANGE[game].items(): if str(uid)[:-8] in digits: return region From 4f1b2ebc5cdea62f65f9348b8fc79d6d12099d18 Mon Sep 17 00:00:00 2001 From: seria Date: Fri, 5 Jul 2024 11:23:54 +0900 Subject: [PATCH 4/9] Return Game.ZZZ for GenshinAccount.game --- genshin/models/hoyolab/record.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/genshin/models/hoyolab/record.py b/genshin/models/hoyolab/record.py index e47225d4..e72fdab7 100644 --- a/genshin/models/hoyolab/record.py +++ b/genshin/models/hoyolab/record.py @@ -46,10 +46,12 @@ class GenshinAccount(APIModel): def game(self) -> types.Game: if "hk4e" in self.game_biz: return types.Game.GENSHIN - elif "bh3" in self.game_biz: + if "bh3" in self.game_biz: return types.Game.HONKAI - elif "hkrpg" in self.game_biz: + if "hkrpg" in self.game_biz: return types.Game.STARRAIL + if "nap" in self.game_biz: + return types.Game.ZZZ try: return types.Game(self.game_biz) From b8d50eb3fb09293c776e87780869ea2750155f7f Mon Sep 17 00:00:00 2001 From: seria Date: Sat, 6 Jul 2024 06:47:03 +0900 Subject: [PATCH 5/9] Prevent enum crashes (#203) --- genshin/models/genshin/chronicle/notes.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/genshin/models/genshin/chronicle/notes.py b/genshin/models/genshin/chronicle/notes.py index aba31f80..0ab5b9d1 100644 --- a/genshin/models/genshin/chronicle/notes.py +++ b/genshin/models/genshin/chronicle/notes.py @@ -97,7 +97,14 @@ class TaskRewardStatus(str, enum.Enum): class TaskReward(APIModel): """Status of the Commission/Task.""" - status: TaskRewardStatus + status: typing.Union[TaskRewardStatus, str] + + @pydantic.validator("status", pre=True) + def __prevent_enum_crash(cls, v: str) -> typing.Union[TaskRewardStatus, str]: + try: + return TaskRewardStatus(v) + except ValueError: + return v class AttendanceRewardStatus(str, enum.Enum): @@ -112,9 +119,16 @@ class AttendanceRewardStatus(str, enum.Enum): class AttendanceReward(APIModel): """Status of the Encounter Point.""" - status: AttendanceRewardStatus + status: typing.Union[AttendanceRewardStatus, str] progress: int + @pydantic.validator("status", pre=True) + def __prevent_enum_crash(cls, v: str) -> typing.Union[AttendanceRewardStatus, str]: + try: + return AttendanceRewardStatus(v) + except ValueError: + return v + class DailyTasks(APIModel): """Daily tasks section.""" From b2a7c3d1e8bf7d3f16a8a3b5b76dfa4f5bf7e0e3 Mon Sep 17 00:00:00 2001 From: seria Date: Sat, 6 Jul 2024 06:50:01 +0900 Subject: [PATCH 6/9] Add Support for Passing in More Device Info (#202) * Update game auth headers with client custom headers * Add device_id and device_fp setter * Allow passing in custom payload * Allow passing in custom device_name, device_model, and client_type * Refactor --- .../client/components/auth/subclients/game.py | 39 +++++++++++++------ genshin/client/components/base.py | 18 +++++++++ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/genshin/client/components/auth/subclients/game.py b/genshin/client/components/auth/subclients/game.py index 417df2ef..d26baed8 100644 --- a/genshin/client/components/auth/subclients/game.py +++ b/genshin/client/components/auth/subclients/game.py @@ -30,9 +30,12 @@ async def _risky_check( if username: payload["username"] = username + headers = auth_utility.RISKY_CHECK_HEADERS.copy() + headers.update(self.custom_headers) + async with aiohttp.ClientSession() as session: async with session.post( - routes.GAME_RISKY_CHECK_URL.get_url(self.region), json=payload, headers=auth_utility.RISKY_CHECK_HEADERS + routes.GAME_RISKY_CHECK_URL.get_url(self.region), json=payload, headers=headers ) as r: data = await r.json() @@ -77,6 +80,8 @@ async def _shield_login( raise ValueError("No default game set.") headers = auth_utility.SHIELD_LOGIN_HEADERS.copy() + headers.update(self.custom_headers) + if mmt_result: headers["x-rpc-risky"] = mmt_result.to_rpc_risky() else: @@ -120,13 +125,21 @@ async def _send_game_verification_email( # noqa: D102 missing docstring in over ) -> typing.Union[None, RiskyCheckMMT]: ... async def _send_game_verification_email( - self, action_ticket: str, *, mmt_result: typing.Optional[RiskyCheckMMTResult] = None + self, + action_ticket: str, + *, + device_model: typing.Optional[str] = None, + device_name: typing.Optional[str] = None, + client_type: typing.Optional[int] = None, + mmt_result: typing.Optional[RiskyCheckMMTResult] = None, ) -> typing.Union[None, RiskyCheckMMT]: """Send email verification code. Returns `None` if success, `RiskyCheckMMT` if geetest verification is required. """ headers = auth_utility.GRANT_TICKET_HEADERS.copy() + headers.update(self.custom_headers) + if mmt_result: headers["x-rpc-risky"] = mmt_result.to_rpc_risky() else: @@ -141,10 +154,10 @@ async def _send_game_verification_email( "way": "Way_Email", "action_ticket": action_ticket, "device": { - "device_model": "iPhone15,4", - "device_id": auth_utility.DEVICE_ID, - "client": 1, - "device_name": "iPhone", + "device_model": device_model or "iPhone15,4", + "device_id": self.device_id or auth_utility.DEVICE_ID, + "client": client_type or 1, + "device_name": device_name or "iPhone", }, } async with aiohttp.ClientSession() as session: @@ -161,10 +174,11 @@ async def _send_game_verification_email( async def _verify_game_email(self, code: str, action_ticket: str) -> DeviceGrantResult: """Verify the email code.""" payload = {"code": code, "ticket": action_ticket} + headers = auth_utility.GRANT_TICKET_HEADERS.copy() + headers.update(self.custom_headers) + async with aiohttp.ClientSession() as session: - async with session.post( - routes.DEVICE_GRANT_URL.get_url(self.region), json=payload, headers=auth_utility.GRANT_TICKET_HEADERS - ) as r: + async with session.post(routes.DEVICE_GRANT_URL.get_url(self.region), json=payload, headers=headers) as r: data = await r.json() return DeviceGrantResult(**data["data"]) @@ -177,17 +191,20 @@ async def _os_game_login(self, uid: str, game_token: str) -> GameLoginResult: payload = { "channel_id": 1, - "device": auth_utility.DEVICE_ID, + "device": self.device_id or auth_utility.DEVICE_ID, "app_id": constants.APP_IDS[self.default_game][self.region], } payload["data"] = json.dumps({"uid": uid, "token": game_token, "guest": False}) payload["sign"] = auth_utility.generate_sign(payload, constants.APP_KEYS[self.default_game][self.region]) + headers = auth_utility.GAME_LOGIN_HEADERS.copy() + headers.update(self.custom_headers) + async with aiohttp.ClientSession() as session: async with session.post( routes.GAME_LOGIN_URL.get_url(self.region, self.default_game), json=payload, - headers=auth_utility.GAME_LOGIN_HEADERS, + headers=headers, ) as r: data = await r.json() diff --git a/genshin/client/components/base.py b/genshin/client/components/base.py index 5d43e286..b6fd9569 100644 --- a/genshin/client/components/base.py +++ b/genshin/client/components/base.py @@ -111,6 +111,24 @@ def __repr__(self) -> str: ) return f"<{type(self).__name__} {', '.join(f'{k}={v!r}' for k, v in kwargs.items() if v)}>" + @property + def device_id(self) -> typing.Optional[str]: + """The device id used in headers.""" + return self.custom_headers.get("x-rpc-device_id") + + @device_id.setter + def device_id(self, device_id: str) -> None: + self.custom_headers["x-rpc-device_id"] = device_id + + @property + def device_fp(self) -> typing.Optional[str]: + """The device fingerprint used in headers.""" + return self.custom_headers.get("x-rpc-device_fp") + + @device_fp.setter + def device_fp(self, device_fp: str) -> None: + self.custom_headers["x-rpc-device_fp"] = device_fp + @property def hoyolab_id(self) -> typing.Optional[int]: """The logged-in user's hoyolab uid. From 985887e1adb60ab87515fbd8450b46d1500c63cd Mon Sep 17 00:00:00 2001 From: seria Date: Sat, 6 Jul 2024 07:03:52 +0900 Subject: [PATCH 7/9] Use GameRoute for battle chronicle route --- genshin/client/components/chronicle/base.py | 10 ++++------ genshin/client/routes.py | 21 +++++++++++++-------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/genshin/client/components/chronicle/base.py b/genshin/client/components/chronicle/base.py index 4e3ba856..03fc67ed 100644 --- a/genshin/client/components/chronicle/base.py +++ b/genshin/client/components/chronicle/base.py @@ -47,13 +47,11 @@ async def request_game_record( **kwargs: typing.Any, ) -> typing.Mapping[str, typing.Any]: """Make a request towards the game record endpoint.""" - if game is types.Game.ZZZ: - base_url = routes.ZZZ_RECORD_URL.get_url(region or self.region) - else: - base_url = routes.RECORD_URL.get_url(region or self.region) - if game is not None: - base_url = base_url / game.value / "api" + game = game or self.default_game + if game is None: + raise RuntimeError("No default game set.") + base_url = routes.RECORD_URL.get_url(region or self.region, game) url = base_url / endpoint mi18n_task = asyncio.create_task(self._fetch_mi18n("bbs", lang=lang or self.lang)) diff --git a/genshin/client/routes.py b/genshin/client/routes.py index 7ef6420f..e08ace22 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -37,7 +37,6 @@ "WEBSTATIC_URL", "WEB_LOGIN_URL", "YSULOG_URL", - "ZZZ_RECORD_URL", "Route", ] @@ -135,13 +134,19 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: overseas="https://bbs-api-os.hoyolab.com/community/", chinese="https://api-takumi-record.mihoyo.com/community/", ) -RECORD_URL = InternationalRoute( - overseas="https://bbs-api-os.hoyolab.com/game_record/", - chinese="https://api-takumi-record.mihoyo.com/game_record/app/", -) -ZZZ_RECORD_URL = InternationalRoute( - overseas="https://sg-act-nap-api.hoyolab.com/event/game_record_zzz/api/zzz/", - chinese="https://api-takumi-record.mihoyo.com/event/game_record_zzz/api/zzz/", +RECORD_URL = GameRoute( + overseas=dict( + genshin="https://bbs-api-os.hoyolab.com/game_record/genshin/api", + hkrpg="https://bbs-api-os.hoyolab.com/game_record/hkrpg/api", + honkai="https://bbs-api-os.hoyolab.com/game_record/honkai3rd/api", + nap="https://sg-act-nap-api.hoyolab.com/event/game_record_zzz/api/zzz", + ), + chinese=dict( + genshin="https://api-takumi-record.mihoyo.com/game_record/app/genshin/api", + hkrpg="https://api-takumi-record.mihoyo.com/game_record/app/hkrpg/api", + honkai="https://api-takumi-record.mihoyo.com/game_record/app/honkai3rd/api", + nap="https://api-takumi-record.mihoyo.com/event/game_record_zzz/api/zzz", + ), ) LINEUP_URL = InternationalRoute( overseas="https://sg-public-api.hoyoverse.com/event/simulatoros/", From 37424c05c512abbbea7945567bc70f27f6d482a9 Mon Sep 17 00:00:00 2001 From: seria Date: Sat, 6 Jul 2024 07:23:15 +0900 Subject: [PATCH 8/9] Fix honkai -> honkai3rd --- genshin/client/routes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/genshin/client/routes.py b/genshin/client/routes.py index e08ace22..6ad468fb 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -138,13 +138,13 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: overseas=dict( genshin="https://bbs-api-os.hoyolab.com/game_record/genshin/api", hkrpg="https://bbs-api-os.hoyolab.com/game_record/hkrpg/api", - honkai="https://bbs-api-os.hoyolab.com/game_record/honkai3rd/api", + honkai3rd="https://bbs-api-os.hoyolab.com/game_record/honkai3rd/api", nap="https://sg-act-nap-api.hoyolab.com/event/game_record_zzz/api/zzz", ), chinese=dict( genshin="https://api-takumi-record.mihoyo.com/game_record/app/genshin/api", hkrpg="https://api-takumi-record.mihoyo.com/game_record/app/hkrpg/api", - honkai="https://api-takumi-record.mihoyo.com/game_record/app/honkai3rd/api", + honkai3rd="https://api-takumi-record.mihoyo.com/game_record/app/honkai3rd/api", nap="https://api-takumi-record.mihoyo.com/event/game_record_zzz/api/zzz", ), ) From d36dbb10555a4e29c8150a0bc3d8f42102570f19 Mon Sep 17 00:00:00 2001 From: seria Date: Sat, 6 Jul 2024 07:27:33 +0900 Subject: [PATCH 9/9] Forgot to add kwargs to overload --- genshin/client/components/auth/subclients/game.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/genshin/client/components/auth/subclients/game.py b/genshin/client/components/auth/subclients/game.py index d26baed8..76dbbede 100644 --- a/genshin/client/components/auth/subclients/game.py +++ b/genshin/client/components/auth/subclients/game.py @@ -113,6 +113,9 @@ async def _send_game_verification_email( # noqa: D102 missing docstring in over self, action_ticket: str, *, + device_model: typing.Optional[str] = None, + device_name: typing.Optional[str] = None, + client_type: typing.Optional[int] = None, mmt_result: RiskyCheckMMTResult, ) -> None: ... @@ -121,6 +124,9 @@ async def _send_game_verification_email( # noqa: D102 missing docstring in over self, action_ticket: str, *, + device_model: typing.Optional[str] = None, + device_name: typing.Optional[str] = None, + client_type: typing.Optional[int] = None, mmt_result: None = ..., ) -> typing.Union[None, RiskyCheckMMT]: ...