From 59f93f16b4e2c5ee0613b6d175f90a31564ea63a Mon Sep 17 00:00:00 2001 From: KT Date: Mon, 3 Jun 2024 17:09:58 +0800 Subject: [PATCH] Squashed commit of the following: commit 9d2009318e1a65bdee3e409b9489dc609a43e207 Author: JokelBaf <60827680+jokelbaf@users.noreply.github.com> Date: Sat Jun 1 15:56:59 2024 +0300 chore: Bump PyPi version commit 462d6e7f453fe9a5d1972cd8d48236c9c3071fcb Author: seria Date: Sat Jun 1 13:25:30 2024 +0900 Remove the use of cookies in GeetestError commit a2b2755ebd889b1d01ab4253f7472539d2a57222 Author: JokelBaf <60827680+jokelbaf@users.noreply.github.com> Date: Sat Jun 1 02:03:12 2024 +0300 fix: Update genshin gacha url commit 76afc281e881aba7c2a5b9c5c795660b2314c0aa Author: JokelBaf <60827680+jokelbaf@users.noreply.github.com> Date: Sat Jun 1 01:52:27 2024 +0300 Add HoYoLab geetest handling (#188) commit 69f82a07511be3aa8a6f23befbb4ab12ff0f947c Author: ashlen Date: Thu May 30 13:05:04 2024 +0000 Bump pypi version commit 1a1c03bada6f215142a5b370f778caea8abfdc0e Author: seria Date: Thu May 23 19:43:43 2024 +0900 Merge dev Branch Into master (#187) * Add geetest v4 support, refactor geetest server code * Don't use pipe for older python versions compatibility * feat: Add create geetest functionality * Use client cookies in `create_geetest` method * Refactor and modelify auth component * Don't use pipe for older python versions compatibility (2) * feat: Add CNWebLoginResult and MobileLoginResult * refactor: Rename methods for better clarity * Add geetest proxying to bypass referrer check * Add hmac hashing function * Implement game login * Add regions decorators * Add error handling to `create_mmt` method * Add HI3 support * Refactor qr code models * Use game-specific `app_id` * refactor: Remove unnecessary cn login headers * refactor: Remove unnecessary headers * style: Format imports * feat: Use client's game when requesting qr code * Don't use explicit defaults in overloads * Use proper RSA key for cn routes * Merge captcha-related HTML * Reformat and fix type errors * Fix QR code imports * Add ZZZ support in game login * fix: Fix mypy errors Fixes error: "type" expects no type arguments, but 1 given [type-arg] Fixes error: "dict" is not subscriptable, use "typing.Dict" instead [misc] * Fix HI3 new battle suit type * Fix icon assertion in tests * Fix QR code login invalid cookies error * Fix test failing because icon URL changed * Fix Signora test icon URL causing test to fail * Remove AccountNotFound exception test * Add `archon_quest_progress` to genshin notes * Add missing models to __all__ * Add value to ArchonQuestStatus enum * Fix daily check-in for Miyoushe Genshin not working * Bypass referer check without using proxy * Allow passing encrypted credentials to all auth funcs * Fix cn daily check-in and update salt * Auto-detect account server during daily check-in * Rename HSR character path field and use enum * Add merged `login_with_password` method * Add missing `encrypted` arg * Rename `encrypt_geetest_credentials` into `encrypt_credentials` * Remove usage of pydantic V2 stuff * Ensure Pydantic V1 compatibility * Add exceptions for auth component * Prevent type check from failing --------- Co-authored-by: JokelBaf <60827680+JokelBaf@users.noreply.github.com> Co-authored-by: Lalatina <111925939+save-lalatina@users.noreply.github.com> commit c32ed4b81d23fa734da768753b566466c23a8d7a Author: seria Date: Sun Apr 14 09:02:51 2024 +0900 Revert qrcode dependency fix (#180) commit 28d3c2e7a1c4ce9eaac6ff27d0f610091bc934c4 Author: seria Date: Sat Apr 13 12:54:00 2024 +0900 Move import statement of qrcode constants into method commit a28a675858c57e648c3ddd6cacf473c6d4951d65 Author: seria Date: Sat Apr 13 12:32:02 2024 +0900 fix: Move qrcode lib import statement into method (#179) commit 47aa014493631e59982a7ec06134062a5dadab2a Author: seria Date: Sat Apr 13 12:27:59 2024 +0900 Improve reformat session in nox (#178) * chore(deps): Remove sort-all from requirements * chore(nox): Update reformat session * style: Format `__all__` commit 524b51a0ce372820831f39bb4f78b053149aadeb Author: ashlen Date: Wed Apr 10 17:49:12 2024 +0000 Bump pypi version commit b83eeb6f62392429d6a9aa10215d8ad5211c5d83 Author: seria Date: Wed Apr 3 09:45:25 2024 +0900 Fix update_charactes_enka function (#176) commit 6b30d30e211b655bee1fcf5aaa342ed4ba0f9564 Author: seria Date: Mon Apr 1 08:36:08 2024 +0900 Fix get_banner_details (#174) * fix: Fix routes * fix: Fix routes and add temp fix commit 45532009be345af2792d6c9c1876d4239ee88ec3 Author: seria Date: Mon Apr 1 07:49:32 2024 +0900 Fix UID utilities to work with new Genshin UID standard (#171) * fix: Fix uid utilities * fix: Fix logic * refactor: Use string slicing approach * refactor: Use in-member check commit 16d7a7c258a1aefefbaa07bbbe0922f0f7239f78 Author: seria Date: Mon Apr 1 07:27:20 2024 +0900 Add QR code login method for Miyoushe (#173) * chore(deps): Add dependencies * feat: Add cookie and qrcode models * feat: Add new routes * feat: Add new ds salt * feat: Add new qrcode-related methods to GeetestClient * feat: Add new game token related functions * feat: Add new ds utilities * Export miyoushe modules * refactor: Refactor passport ds utility func * chore: Run nox * chore(deps): Merge qrcode and pillow * fix: Change account_id type to int * feat: Add public method to client * feat: Set cookies to client after obtaining cookies * chore(deps): Add qrcode[pil] to geetest extras * docs: Add docstring for private methods commit 803b94d0444fb973e820b6cdc8bbac704c863e60 Author: seria Date: Thu Mar 28 23:34:44 2024 +0900 Migrate to ruff (#168) commit f6f4c11bd22abe5b31c741db5a29598480c8bf27 Author: ashlen Date: Wed Mar 27 12:02:03 2024 +0100 Add star-rail notes to cli (#170) resolves #169 commit b3873600ea6c78f64b112679e23c04860a9a0394 Author: seria Date: Tue Mar 26 06:55:35 2024 +0800 Add Miyoushe login methods (#167) * feat: Add CN password login * refactor: Refactor parse cookie code * style: Run nox * feat: Add OTP-related methods * refactor: Refactor according to reviews * refactor: Refactor according to reviews commit f23467a0bcfabcdf08376946d5845c0c8528aa68 Author: seria Date: Fri Mar 15 23:19:29 2024 +0900 Add missing skill attribute in StarRailDetailCharacter (#166) * fix: Add missing skills attribute in hsr chara commit 7b0af4ecbc72cc8dd4dfd98b4cb977e3d9bb375e Author: seria Date: Fri Mar 15 18:06:18 2024 +0900 Change icon provider to Enka (#165) * fix: Use Enka as new icon provider * Run nox * feat: Add new gacha_art property Add deprecation notice to image property Change image property to return gacha_art instead commit dcbe321e9247acf20366e38e5ba106c953926266 Author: Igor Tankov <75366889a@gmail.com> Date: Wed Mar 13 19:54:57 2024 +0300 Update MI18N bbs link (#164) commit be182244486bdecc1d160853bcb122f008bd2448 Merge: 26dc4a5 e50fc4c Author: seria Date: Tue Mar 12 11:04:05 2024 +0900 Merge pull request #163 from seriaati/master Fix missed_rewards property in DailyRewardInfo model commit e50fc4cd03746f9502990f3bce4152a2e0b72363 Author: seria Date: Tue Mar 12 09:57:35 2024 +0800 Run nox commit c963822e7a017afabc7296199d212582122d788b Author: seria Date: Tue Mar 12 09:15:52 2024 +0800 fix: Fix missed_rewards property calc method commit 873cfac5e28779901c0c699cb1c063c938c489b3 Author: seria Date: Tue Mar 12 08:55:05 2024 +0800 fix: Fix KeyError if account has never done check-in commit 2c510f83a9422f351828a108fb24c2b7239168a3 Author: seria Date: Tue Mar 12 08:29:15 2024 +0800 fix: Fix wrong missed_rewards count in DailyRewardInfo commit 26dc4a521a2c1c047095abc49848dd64ba8ed992 Author: JokelBaf <60827680+JokelBaf@users.noreply.github.com> Date: Wed Mar 6 16:30:32 2024 +0200 Complete hsr characters endpoint support (#161) commit ed913637a0a28ba538974702fb2dff6c46523529 Merge: 5a4a840 1c2e41d Author: Konard Date: Tue Mar 5 17:35:43 2024 -0600 Merge pull request #162 from seriaati/master Update StarRailExpedition model commit 1c2e41d5b79dcf04f3f5a76dbb113b7d44a4d1e6 Author: seria Date: Tue Mar 5 14:42:54 2024 +0800 feat: Add `item_url` to `StarRailExpedition` commit 5a4a840b656c5bbf6999a3135046b6f1b9e5fc6e Merge: a424187 9b31827 Author: JokelBaf <60827680+JokelBaf@users.noreply.github.com> Date: Mon Mar 4 17:16:15 2024 +0200 Merge pull request #160 from JokelBaf/master Fixes and improvements in geetest module commit 9b3182771f1bf1cb03d5f35fc5fcb850ff638c19 Author: JokelBaf <60827680+JokelBaf@users.noreply.github.com> Date: Mon Mar 4 17:08:28 2024 +0200 Fixes and improvements in geetest module commit a424187d1a085980cf9a4f0142c3a0d099be4c94 Author: Zhi Heng Date: Tue Feb 6 22:25:39 2024 +0800 Make HSR Pure Fiction floor buff optional (#158) commit 202dc9ea2580623aa493eb6a051c7b2ec560a95b Author: KT Date: Sat Feb 3 07:38:32 2024 +0800 Add HSR pure fiction (#157) --- .flake8 | 44 --- genshin-dev/lint-requirements.txt | 14 +- genshin-dev/reformat-requirements.txt | 3 +- genshin/__main__.py | 35 +- genshin/client/clients.py | 4 +- genshin/client/components/auth/__init__.py | 10 + genshin/client/components/auth/client.py | 367 ++++++++++++++++++ genshin/client/components/auth/server.py | 266 +++++++++++++ .../components/auth/subclients/__init__.py | 5 + .../client/components/auth/subclients/app.py | 225 +++++++++++ .../client/components/auth/subclients/game.py | 197 ++++++++++ .../client/components/auth/subclients/web.py | 232 +++++++++++ genshin/client/components/base.py | 72 +++- genshin/client/components/daily.py | 19 +- genshin/client/components/gacha.py | 13 +- genshin/client/components/geetest/__init__.py | 6 - genshin/client/components/geetest/client.py | 258 ------------ genshin/client/components/geetest/server.py | 163 -------- genshin/client/components/transaction.py | 2 +- genshin/client/manager/cookie.py | 54 ++- genshin/client/routes.py | 83 +++- genshin/constants.py | 56 ++- genshin/errors.py | 102 ++++- genshin/models/__init__.py | 1 + genshin/models/auth/__init__.py | 6 + genshin/models/auth/cookie.py | 144 +++++++ genshin/models/auth/geetest.py | 173 +++++++++ genshin/models/auth/qrcode.py | 61 +++ genshin/models/auth/responses.py | 53 +++ genshin/models/auth/verification.py | 46 +++ genshin/models/genshin/character.py | 26 +- genshin/models/genshin/chronicle/abyss.py | 8 +- genshin/models/genshin/chronicle/notes.py | 30 ++ genshin/models/genshin/chronicle/stats.py | 2 +- genshin/models/genshin/daily.py | 4 +- genshin/models/genshin/lineup.py | 2 +- genshin/models/genshin/teapot.py | 2 +- genshin/models/honkai/battlesuit.py | 1 + genshin/models/honkai/chronicle/modes.py | 2 +- genshin/models/honkai/chronicle/stats.py | 5 +- genshin/models/hoyolab/announcements.py | 2 +- .../models/starrail/chronicle/challenge.py | 10 +- .../models/starrail/chronicle/characters.py | 170 +++++++- genshin/models/starrail/chronicle/notes.py | 1 + genshin/paginators/base.py | 2 +- genshin/types.py | 3 + genshin/utility/__init__.py | 2 +- genshin/utility/auth.py | 170 ++++++++ genshin/utility/ds.py | 27 +- genshin/utility/extdb.py | 9 +- genshin/utility/geetest.py | 65 ---- noxfile.py | 28 +- pyproject.toml | 68 +++- requirements.txt | 1 + setup.py | 7 +- tests/client/components/test_calculator.py | 2 +- tests/client/components/test_daily.py | 4 +- .../components/test_genshin_chronicle.py | 3 - tests/models/test_model.py | 16 +- 59 files changed, 2731 insertions(+), 655 deletions(-) delete mode 100644 .flake8 create mode 100644 genshin/client/components/auth/__init__.py create mode 100644 genshin/client/components/auth/client.py create mode 100644 genshin/client/components/auth/server.py create mode 100644 genshin/client/components/auth/subclients/__init__.py create mode 100644 genshin/client/components/auth/subclients/app.py create mode 100644 genshin/client/components/auth/subclients/game.py create mode 100644 genshin/client/components/auth/subclients/web.py delete mode 100644 genshin/client/components/geetest/__init__.py delete mode 100644 genshin/client/components/geetest/client.py delete mode 100644 genshin/client/components/geetest/server.py create mode 100644 genshin/models/auth/__init__.py create mode 100644 genshin/models/auth/cookie.py create mode 100644 genshin/models/auth/geetest.py create mode 100644 genshin/models/auth/qrcode.py create mode 100644 genshin/models/auth/responses.py create mode 100644 genshin/models/auth/verification.py create mode 100644 genshin/utility/auth.py delete mode 100644 genshin/utility/geetest.py diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 0c5414ec..00000000 --- a/.flake8 +++ /dev/null @@ -1,44 +0,0 @@ -[flake8] -exclude = tests, test.py - -# A001, A002, A003: `id` variable/parameter/attribute -# C408: dict() with keyword arguments -# D105: Missing docstring in magic method -# D106: Missing docstring Model.Config -# D419: Docstring is empty -# S101: Use of assert for type checking -# S303: Use of md5 -# S311: Use of pseudo-random generators -# S324: Use of md5 without usedforsecurity=False (3.9+) -# W503: line break before binary operator -ignore = - A001, A002, A003, - C408, - D105, D106, D419 - S101, S303, S311, S324, - W503, - -# Recommended by black -extend-ignore = E203, E704 - -# F401: unused import. -# F403: cannot detect unused vars if we use starred import -# D10*: docstrings -# S10*: hardcoded passwords -# F841: unused variable -# I900: nox is a dev dependency -per-file-ignores = - **/__init__.py: F401, F403 - tests/**: D10, S10, F841 - noxfile.py: I900 - -max-complexity = 16 -max-function-length = 100 -max-line-length = 130 - -max_annotations_complexity = 5 - -accept-encodings = utf-8 -docstring-convention = numpy -ignore-decorators = property -requirements_file = requirements.txt \ No newline at end of file diff --git a/genshin-dev/lint-requirements.txt b/genshin-dev/lint-requirements.txt index 4073601f..ede3eb4e 100644 --- a/genshin-dev/lint-requirements.txt +++ b/genshin-dev/lint-requirements.txt @@ -1,13 +1 @@ -flake8 - -flake8-annotations-complexity # complex annotation -flake8-black # runs black -flake8-builtins # builtin shadowing -flake8-docstrings # proper formatting and grammar in docstrings -flake8-isort # runs isort -flake8-mutable # mutable default argument detection -flake8-pep3101 # new-style format strings only -flake8-print # complain about print statements in code -flake8-pytest-style # pytest checks -flake8-raise # exception raising -flake8-requirements # requirements.txt check +ruff \ No newline at end of file diff --git a/genshin-dev/reformat-requirements.txt b/genshin-dev/reformat-requirements.txt index bee49858..6b7ba109 100644 --- a/genshin-dev/reformat-requirements.txt +++ b/genshin-dev/reformat-requirements.txt @@ -1,3 +1,2 @@ black -isort -sort-all +ruff \ No newline at end of file diff --git a/genshin/__main__.py b/genshin/__main__.py index 2f7e8f3a..2904cf03 100644 --- a/genshin/__main__.py +++ b/genshin/__main__.py @@ -68,8 +68,10 @@ async def accounts(client: genshin.Client) -> None: genshin_group: click.Group = click.Group("genshin", help="Genshin-related commands.") honkai_group: click.Group = click.Group("honkai", help="Honkai-related commands.") +starrail_group: click.Group = click.Group("starrail", help="StarRail-related commands.") cli.add_command(genshin_group) cli.add_command(honkai_group) +cli.add_command(starrail_group) @honkai_group.command("stats") @@ -86,7 +88,7 @@ async def honkai_stats(client: genshin.Client, uid: int) -> None: for k, v in data.stats.as_dict(lang=client.lang).items(): if isinstance(v, dict): click.echo(f"{k}:") - for nested_k, nested_v in typing.cast("dict[str, object]", v).items(): + for nested_k, nested_v in typing.cast("typing.Dict[str, object]", v).items(): click.echo(f" {nested_k}: {click.style(str(nested_v), bold=True)}") else: click.echo(f"{k}: {click.style(str(v), bold=True)}") @@ -199,6 +201,33 @@ async def genshin_notes(client: genshin.Client, uid: typing.Optional[int]) -> No click.echo(f" - {expedition.status}") +@starrail_group.command("notes") +@click.argument("uid", type=int, default=None, required=False) +@client_command +async def starrail_notes(client: genshin.Client, uid: typing.Optional[int]) -> None: + """Show real-Time starrail notes.""" + click.echo("Real-Time notes.") + + data = await client.get_starrail_notes(uid) + + click.echo(f"{click.style('TB power:', bold=True)} {data.current_stamina}/{data.max_stamina}", nl=False) + click.echo(f" (Full in {data.stamina_recover_time})" if data.stamina_recover_time > datetime.timedelta(0) else "") + click.echo(f"{click.style('Reserved TB power:', bold=True)} {data.current_reserve_stamina}/2400") + click.echo(f"{click.style('Daily training:', bold=True)} {data.current_train_score}/{data.max_train_score}") + click.echo(f"{click.style('Simulated Universe:', bold=True)} {data.current_rogue_score}/{data.max_rogue_score}") + click.echo( + f"{click.style('Echo of War:', bold=True)} {data.remaining_weekly_discounts}/{data.max_weekly_discounts}" + ) + + click.echo(f"\n{click.style('Assignments:', bold=True)} {data.accepted_epedition_num}/{data.total_expedition_num}") + for expedition in data.expeditions: + if expedition.remaining_time > datetime.timedelta(0): + remaining = f"{expedition.remaining_time} remaining" + click.echo(f" - {expedition.name} | {remaining}") + else: + click.echo(f" - {expedition.name} | Finished") + + @cli.command() @click.option("--scenario", help="Scenario ID or name to use (eg '12-3').", type=str, default=None) @client_command @@ -309,8 +338,8 @@ def authkey() -> None: async def login(account: str, password: str, port: int) -> None: """Login with a password.""" client = genshin.Client() - cookies = await client.login_with_password(account, password, port=port) - cookies = await genshin.complete_cookies(cookies) + result = await client.os_login_with_password(account, password, port=port) + cookies = await genshin.complete_cookies(result.dict()) base: http.cookies.BaseCookie[str] = http.cookies.BaseCookie(cookies) click.echo(f"Your cookies are: {click.style(base.output(header='', sep=';'), bold=True)}") diff --git a/genshin/client/clients.py b/genshin/client/clients.py index 87e8aa9d..99baf8ab 100644 --- a/genshin/client/clients.py +++ b/genshin/client/clients.py @@ -1,12 +1,12 @@ """A simple HTTP client for API endpoints.""" from .components import ( + auth, calculator, chronicle, daily, diary, gacha, - geetest, hoyolab, lineup, teapot, @@ -28,6 +28,6 @@ class Client( wiki.WikiClient, gacha.WishClient, transaction.TransactionClient, - geetest.GeetestClient, + auth.AuthClient, ): """A simple HTTP client for API endpoints.""" diff --git a/genshin/client/components/auth/__init__.py b/genshin/client/components/auth/__init__.py new file mode 100644 index 00000000..7e9ded02 --- /dev/null +++ b/genshin/client/components/auth/__init__.py @@ -0,0 +1,10 @@ +"""Auth-related utility. + +Credits to: +- JokelBaf - https://github.com/jokelbaf +- Seria - https://github.com/seriaati +- M-307 - https://github.com/mrwan200 +- gsuid_core - https://github.com/Genshin-bots/gsuid_core +""" + +from .client import * diff --git a/genshin/client/components/auth/client.py b/genshin/client/components/auth/client.py new file mode 100644 index 00000000..0bbe36b9 --- /dev/null +++ b/genshin/client/components/auth/client.py @@ -0,0 +1,367 @@ +"""Main auth client.""" + +import asyncio +import logging +import typing + +import aiohttp + +from genshin import constants, errors, types +from genshin.client import routes +from genshin.client.components import base +from genshin.client.manager import managers +from genshin.client.manager.cookie import fetch_cookie_token_with_game_token, fetch_stoken_with_game_token +from genshin.models.auth.cookie import ( + AppLoginResult, + CNWebLoginResult, + GameLoginResult, + MobileLoginResult, + QRLoginResult, + WebLoginResult, +) +from genshin.models.auth.geetest import MMT, MMTResult, RiskyCheckMMT, RiskyCheckMMTResult, SessionMMT, SessionMMTResult +from genshin.models.auth.qrcode import QRCodeStatus +from genshin.models.auth.verification import ActionTicket +from genshin.utility import auth as auth_utility +from genshin.utility import ds as ds_utility + +from . import server, subclients + +__all__ = ["AuthClient"] + +LOGGER_ = logging.getLogger(__name__) + + +class AuthClient(subclients.AppAuthClient, subclients.WebAuthClient, subclients.GameAuthClient): + """Auth client component.""" + + async def login_with_password( + self, + account: str, + password: str, + *, + port: int = 5000, + encrypted: bool = False, + geetest_solver: typing.Optional[typing.Callable[[SessionMMT], typing.Awaitable[SessionMMTResult]]] = None, + ) -> typing.Union[WebLoginResult, CNWebLoginResult]: + """Login with a password via web endpoint. + + Endpoint is chosen based on client region. + + Note that this will start a webserver if captcha is + triggered and `geetest_solver` is not passed. + + Raises + ------ + - AccountLoginFail: Invalid password provided. + - AccountDoesNotExist: Invalid email/username. + """ + if self.region is types.Region.CHINESE: + return await self.cn_login_with_password( + account, password, encrypted=encrypted, port=port, geetest_solver=geetest_solver + ) + + return await self.os_login_with_password( + account, password, port=port, encrypted=encrypted, geetest_solver=geetest_solver + ) + + @base.region_specific(types.Region.OVERSEAS) + async def os_login_with_password( + self, + account: str, + password: str, + *, + port: int = 5000, + encrypted: bool = False, + token_type: typing.Optional[int] = 6, + geetest_solver: typing.Optional[typing.Callable[[SessionMMT], typing.Awaitable[SessionMMTResult]]] = None, + ) -> WebLoginResult: + """Login with a password via web endpoint. + + Note that this will start a webserver if captcha is + triggered and `geetest_solver` is not passed. + + Raises + ------ + - AccountLoginFail: Invalid password provided. + - AccountDoesNotExist: Invalid email/username. + """ + result = await self._os_web_login(account, password, encrypted=encrypted, token_type=token_type) + + if not isinstance(result, SessionMMT): + # Captcha not triggered + return result + + if geetest_solver: + mmt_result = await geetest_solver(result) + else: + mmt_result = await server.solve_geetest(result, port=port) + + return await self._os_web_login( + account, password, encrypted=encrypted, token_type=token_type, mmt_result=mmt_result + ) + + @base.region_specific(types.Region.CHINESE) + async def cn_login_with_password( + self, + account: str, + password: str, + *, + encrypted: bool = False, + port: int = 5000, + geetest_solver: typing.Optional[typing.Callable[[SessionMMT], typing.Awaitable[SessionMMTResult]]] = None, + ) -> CNWebLoginResult: + """Login with a password via Miyoushe loginByPassword endpoint. + + Note that this will start a webserver if captcha is + triggered and `geetest_solver` is not passed. + """ + result = await self._cn_web_login(account, password, encrypted=encrypted) + + if not isinstance(result, SessionMMT): + # Captcha not triggered + return result + + if geetest_solver: + mmt_result = await geetest_solver(result) + else: + mmt_result = await server.solve_geetest(result, port=port) + + return await self._cn_web_login(account, password, encrypted=encrypted, mmt_result=mmt_result) + + @base.region_specific(types.Region.OVERSEAS) + async def check_mobile_number_validity(self, mobile: str) -> bool: + """Check if a mobile number is valid (it's registered on Miyoushe). + + Returns True if the mobile number is valid, False otherwise. + """ + async with aiohttp.ClientSession() as session: + async with session.get( + routes.CHECK_MOBILE_VALIDITY_URL.get_url(), + params={"mobile": mobile}, + ) as r: + data = await r.json() + + return data["data"]["status"] != data["data"]["is_registable"] + + @base.region_specific(types.Region.CHINESE) + async def login_with_mobile_number( + self, + mobile: str, + *, + encrypted: bool = False, + port: int = 5000, + ) -> MobileLoginResult: + """Login with mobile number, returns cookies. + + Only works for Chinese region (Miyoushe) users, do not include + area code (+86) in the mobile number. + + Steps: + 1. Sends OTP to the provided mobile number. + 2. If captcha is triggered, prompts the user to solve it. + 3. Lets user enter the OTP. + 4. Logs in with the OTP. + 5. Returns cookies. + """ + result = await self._send_mobile_otp(mobile, encrypted=encrypted) + + if isinstance(result, SessionMMT): + # Captcha triggered + mmt_result = await server.solve_geetest(result, port=port) + await self._send_mobile_otp(mobile, encrypted=encrypted, mmt_result=mmt_result) + + otp = await server.enter_code(port=port) + return await self._login_with_mobile_otp(mobile, otp, encrypted=encrypted) + + @base.region_specific(types.Region.OVERSEAS) + async def login_with_app_password( + self, + account: str, + password: str, + *, + encrypted: bool = False, + port: int = 5000, + geetest_solver: typing.Optional[typing.Callable[[SessionMMT], typing.Awaitable[SessionMMTResult]]] = None, + ) -> AppLoginResult: + """Login with a password via HoYoLab app endpoint. + + Note that this will start a webserver if either of the + following happens: + + 1. Captcha is triggered and `geetest_solver` is not passed. + 2. Email verification is triggered (can happen if you + first login with a new device). + + Raises + ------ + - AccountLoginFail: Invalid password provided. + - AccountDoesNotExist: Invalid email/username. + - VerificationCodeRateLimited: Too many verification code requests. + """ + result = await self._app_login(account, password, encrypted=encrypted) + + if isinstance(result, SessionMMT): + # Captcha triggered + if geetest_solver: + mmt_result = await geetest_solver(result) + else: + mmt_result = await server.solve_geetest(result, port=port) + + result = await self._app_login(account, password, encrypted=encrypted, mmt_result=mmt_result) + + if isinstance(result, ActionTicket): + # Email verification required + mmt = await self._send_verification_email(result) + if mmt: + if geetest_solver: + mmt_result = await geetest_solver(mmt) + else: + mmt_result = await server.solve_geetest(mmt, port=port) + + await asyncio.sleep(2) # Add delay to prevent [-3206] + await self._send_verification_email(result, mmt_result=mmt_result) + + code = await server.enter_code(port=port) + await self._verify_email(code, result) + + result = await self._app_login(account, password, encrypted=encrypted, ticket=result) + + return result + + @base.region_specific(types.Region.CHINESE) + async def login_with_qrcode(self) -> QRLoginResult: + """Login with QR code, only available for Miyoushe users.""" + import qrcode + import qrcode.image.pil + from qrcode.constants import ERROR_CORRECT_L + + creation_result = await self._create_qrcode() + qrcode_: qrcode.image.pil.PilImage = qrcode.make(creation_result.url, error_correction=ERROR_CORRECT_L) # type: ignore + qrcode_.show() + + scanned = False + while True: + check_result = await self._check_qrcode( + creation_result.app_id, creation_result.device_id, creation_result.ticket + ) + if check_result.status == QRCodeStatus.SCANNED and not scanned: + LOGGER_.info("QR code scanned") + scanned = True + elif check_result.status == QRCodeStatus.CONFIRMED: + LOGGER_.info("QR code login confirmed") + break + + await asyncio.sleep(2) + + raw_data = check_result.payload.raw + assert raw_data is not None + + cookie_token = await fetch_cookie_token_with_game_token( + game_token=raw_data.game_token, account_id=raw_data.account_id + ) + stoken = await fetch_stoken_with_game_token(game_token=raw_data.game_token, account_id=int(raw_data.account_id)) + + cookies = { + "stoken_v2": stoken.token, + "ltuid": stoken.aid, + "account_id": stoken.aid, + "ltmid": stoken.mid, + "cookie_token": cookie_token, + } + self.set_cookies(cookies) + return QRLoginResult(**cookies) + + @managers.no_multi + async def create_mmt(self) -> MMT: + """Create a geetest challenge.""" + if self.default_game is None: + raise ValueError("No default game set.") + + headers = { + "DS": ds_utility.generate_geetest_ds(self.region), + **auth_utility.CREATE_MMT_HEADERS[self.region], + } + + url = routes.CREATE_MMT_URL.get_url(self.region) + if self.region is types.Region.OVERSEAS: + url = url.update_query(app_key=constants.GEETEST_RECORD_KEYS[self.default_game]) + + assert isinstance(self.cookie_manager, managers.CookieManager) + async with self.cookie_manager.create_session() as session: + async with session.get(url, headers=headers, cookies=self.cookie_manager.cookies) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + return MMT(**data["data"]) + + @base.region_specific(types.Region.OVERSEAS) + @managers.no_multi + async def verify_mmt(self, mmt_result: MMTResult) -> None: + """Verify a geetest challenge.""" + if self.default_game is None: + raise ValueError("No default game set.") + + headers = { + "DS": ds_utility.generate_geetest_ds(self.region), + **auth_utility.CREATE_MMT_HEADERS[self.region], + } + + body = mmt_result.dict() + body["app_key"] = constants.GEETEST_RECORD_KEYS[self.default_game] + + assert isinstance(self.cookie_manager, managers.CookieManager) + async with self.cookie_manager.create_session() as session: + async with session.post( + routes.VERIFY_MMT_URL.get_url(), json=body, headers=headers, cookies=self.cookie_manager.cookies + ) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + @base.region_specific(types.Region.OVERSEAS) + async def os_game_login( + self, + account: str, + password: str, + *, + encrypted: bool = False, + port: int = 5000, + geetest_solver: typing.Optional[typing.Callable[[RiskyCheckMMT], typing.Awaitable[RiskyCheckMMTResult]]] = None, + ) -> GameLoginResult: + """Perform a login to the game. + + Raises + ------ + - IncorrectGameAccount: Invalid account provided. + - IncorrectGamePassword: Invalid password provided. + """ + result = await self._shield_login(account, password, encrypted=encrypted) + + if isinstance(result, RiskyCheckMMT): + if geetest_solver: + mmt_result = await geetest_solver(result) + else: + mmt_result = await server.solve_geetest(result, port=port) + + result = await self._shield_login(account, password, encrypted=encrypted, mmt_result=mmt_result) + + if not result.device_grant_required: + return await self._os_game_login(result.account.uid, result.account.token) + + mmt = await self._send_game_verification_email(result.account.device_grant_ticket) + if mmt: + if geetest_solver: + mmt_result = await geetest_solver(mmt) + else: + mmt_result = await server.solve_geetest(mmt, port=port) + + await self._send_game_verification_email(result.account.device_grant_ticket, mmt_result=mmt_result) + + code = await server.enter_code() + verification_result = await self._verify_game_email(code, result.account.device_grant_ticket) + + return await self._os_game_login(result.account.uid, verification_result.game_token) diff --git a/genshin/client/components/auth/server.py b/genshin/client/components/auth/server.py new file mode 100644 index 00000000..d6ef8248 --- /dev/null +++ b/genshin/client/components/auth/server.py @@ -0,0 +1,266 @@ +"""Aiohttp webserver used for captcha solving and verification.""" + +from __future__ import annotations + +import asyncio +import typing +import webbrowser + +import aiohttp +from aiohttp import web + +from genshin.models.auth.geetest import ( + MMT, + MMTResult, + MMTv4, + MMTv4Result, + RiskyCheckMMT, + RiskyCheckMMTResult, + SessionMMT, + SessionMMTResult, + SessionMMTv4, + SessionMMTv4Result, +) +from genshin.utility import auth as auth_utility + +__all__ = ["PAGES", "enter_code", "launch_webapp", "solve_geetest"] + +PAGES: typing.Final[typing.Dict[typing.Literal["captcha", "enter-code"], str]] = { + "captcha": """ + + + + + + + + + + """, + "enter-code": """ + + + + + + + + + """, +} + + +GT_V3_URL = "https://static.geetest.com/static/js/gt.0.5.0.js" +GT_V4_URL = "https://static.geetest.com/v4/gt4.js" + + +@typing.overload +async def launch_webapp( + page: typing.Literal["captcha"], + *, + mmt: typing.Union[MMT, MMTv4, SessionMMT, SessionMMTv4, RiskyCheckMMT], + lang: str = ..., + api_server: str = ..., + port: int = ..., +) -> typing.Union[MMTResult, MMTv4Result, SessionMMTResult, SessionMMTv4Result, RiskyCheckMMTResult]: ... +@typing.overload +async def launch_webapp( + page: typing.Literal["enter-code"], + *, + mmt: None = ..., + lang: None = ..., + api_server: None = ..., + port: int = ..., +) -> str: ... +async def launch_webapp( + page: typing.Literal["captcha", "enter-code"], + *, + mmt: typing.Optional[typing.Union[MMT, MMTv4, SessionMMT, SessionMMTv4, RiskyCheckMMT]] = None, + lang: typing.Optional[str] = None, + api_server: typing.Optional[str] = None, + port: int = 5000, +) -> typing.Union[MMTResult, MMTv4Result, SessionMMTResult, SessionMMTv4Result, RiskyCheckMMTResult, str]: + """Create and run a webapp to solve captcha or enter a verification code.""" + routes = web.RouteTableDef() + future: asyncio.Future[typing.Any] = asyncio.Future() + + @routes.get("/") + async def index(request: web.Request) -> web.StreamResponse: + body = PAGES[page] + body = body.replace("{gt_version}", "4" if isinstance(mmt, MMTv4) else "3") + body = body.replace("{api_server}", api_server or "api-na.geetest.com") + body = body.replace("{lang}", lang or "en") + return web.Response(body=body, content_type="text/html") + + @routes.get("/gt/{version}.js") + async def gt(request: web.Request) -> web.StreamResponse: + version = request.match_info.get("version", "v3") + gt_url = GT_V4_URL if version == "v4" else GT_V3_URL + + async with aiohttp.ClientSession() as session: + r = await session.get(gt_url) + content = await r.read() + + return web.Response(body=content, content_type="text/javascript") + + @routes.get("/mmt") + async def mmt_endpoint(request: web.Request) -> web.Response: + return web.json_response(mmt.dict() if mmt else {}) + + @routes.post("/send-data") + async def send_data_endpoint(request: web.Request) -> web.Response: + result = await request.json() + if "code" in result: + result = result["code"] + else: + if isinstance(mmt, RiskyCheckMMT): + result = RiskyCheckMMTResult(**result) + elif isinstance(mmt, SessionMMT): + result = SessionMMTResult(**result) + elif isinstance(mmt, SessionMMTv4): + result = SessionMMTv4Result(**result) + elif isinstance(mmt, MMT): + result = MMTResult(**result) + elif isinstance(mmt, MMTv4): + result = MMTv4Result(**result) + + future.set_result(result) + return web.Response(status=204) + + app = web.Application() + app.add_routes(routes) + + runner = web.AppRunner(app) + await runner.setup() + + site = web.TCPSite(runner, host="localhost", port=port) + print(f"Opening http://localhost:{port} in browser...") # noqa + webbrowser.open_new_tab(f"http://localhost:{port}") + + await site.start() + + try: + data = await future + finally: + await asyncio.sleep(0.3) + await runner.shutdown() + await runner.cleanup() + + return data + + +@typing.overload +async def solve_geetest( + mmt: RiskyCheckMMT, + *, + lang: str = ..., + api_server: str = ..., + port: int = ..., +) -> RiskyCheckMMTResult: ... +@typing.overload +async def solve_geetest( + mmt: SessionMMT, + *, + lang: str = ..., + api_server: str = ..., + port: int = ..., +) -> SessionMMTResult: ... +@typing.overload +async def solve_geetest( + mmt: MMT, + *, + lang: str = ..., + api_server: str = ..., + port: int = ..., +) -> MMTResult: ... +@typing.overload +async def solve_geetest( + mmt: SessionMMTv4, + *, + lang: str = ..., + api_server: str = ..., + port: int = ..., +) -> SessionMMTv4Result: ... +@typing.overload +async def solve_geetest( + mmt: MMTv4, + *, + lang: str = ..., + api_server: str = ..., + port: int = ..., +) -> MMTv4Result: ... +async def solve_geetest( + mmt: typing.Union[MMT, MMTv4, SessionMMT, SessionMMTv4, RiskyCheckMMT], + *, + lang: str = "en-us", + api_server: str = "api-na.geetest.com", + port: int = 5000, +) -> typing.Union[MMTResult, MMTv4Result, SessionMMTResult, SessionMMTv4Result, RiskyCheckMMTResult]: + """Start a web server and manually solve geetest captcha.""" + lang = auth_utility.lang_to_geetest_lang(lang) + return await launch_webapp( + "captcha", + mmt=mmt, + lang=lang, + api_server=api_server, + port=port, + ) + + +async def enter_code(*, port: int = 5000) -> str: + """Get email or phone number verification code from user.""" + return await launch_webapp("enter-code", port=port) diff --git a/genshin/client/components/auth/subclients/__init__.py b/genshin/client/components/auth/subclients/__init__.py new file mode 100644 index 00000000..566790b5 --- /dev/null +++ b/genshin/client/components/auth/subclients/__init__.py @@ -0,0 +1,5 @@ +"""Sub clients for AuthClient. Separated by functionality.""" + +from .app import * +from .game import * +from .web import * diff --git a/genshin/client/components/auth/subclients/app.py b/genshin/client/components/auth/subclients/app.py new file mode 100644 index 00000000..61662c18 --- /dev/null +++ b/genshin/client/components/auth/subclients/app.py @@ -0,0 +1,225 @@ +"""App sub client for AuthClient. + +Covers HoYoLAB and Miyoushe app auth endpoints. +""" + +import json +import random +import typing +from string import ascii_letters, digits + +import aiohttp + +from genshin import constants, errors +from genshin.client import routes +from genshin.client.components import base +from genshin.models.auth.cookie import AppLoginResult +from genshin.models.auth.geetest import SessionMMT, SessionMMTResult +from genshin.models.auth.qrcode import QRCodeCheckResult, QRCodeCreationResult +from genshin.models.auth.verification import ActionTicket +from genshin.utility import auth as auth_utility +from genshin.utility import ds as ds_utility + +__all__ = ["AppAuthClient"] + + +class AppAuthClient(base.BaseClient): + """App sub client for AuthClient.""" + + @typing.overload + async def _app_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + mmt_result: SessionMMTResult, + ticket: None = ..., + ) -> typing.Union[AppLoginResult, ActionTicket]: ... + + @typing.overload + async def _app_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + mmt_result: None = ..., + ticket: ActionTicket, + ) -> AppLoginResult: ... + + @typing.overload + async def _app_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + mmt_result: None = ..., + ticket: None = ..., + ) -> typing.Union[AppLoginResult, SessionMMT, ActionTicket]: ... + + async def _app_login( + self, + account: str, + password: str, + *, + encrypted: bool = False, + mmt_result: typing.Optional[SessionMMTResult] = None, + ticket: typing.Optional[ActionTicket] = None, + ) -> typing.Union[AppLoginResult, SessionMMT, ActionTicket]: + """Login with a password using HoYoLab app endpoint. + + Returns + ------- + - Cookies if login is successful. + - SessionMMT if captcha is triggered. + - ActionTicket if email verification is required. + """ + headers = { + **auth_utility.APP_LOGIN_HEADERS, + "ds": ds_utility.generate_dynamic_secret(constants.DS_SALT["app_login"]), + } + if mmt_result: + headers["x-rpc-aigis"] = mmt_result.to_aigis_header() + + if ticket: + headers["x-rpc-verify"] = ticket.to_rpc_verify_header() + + payload = { + "account": account if encrypted else auth_utility.encrypt_credentials(account, 1), + "password": password if encrypted else auth_utility.encrypt_credentials(password, 1), + } + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.APP_LOGIN_URL.get_url(), + json=payload, + headers=headers, + ) as r: + data = await r.json() + + if data["retcode"] == -3101: + # Captcha triggered + aigis = json.loads(r.headers["x-rpc-aigis"]) + return SessionMMT(**aigis) + + if data["retcode"] == -3239: + # Email verification required + action_ticket = json.loads(r.headers["x-rpc-verify"]) + return ActionTicket(**action_ticket) + + if not data["data"]: + errors.raise_for_retcode(data) + + cookies = { + "stoken": data["data"]["token"]["token"], + "ltuid_v2": data["data"]["user_info"]["aid"], + "ltmid_v2": data["data"]["user_info"]["mid"], + "account_id_v2": data["data"]["user_info"]["aid"], + "account_mid_v2": data["data"]["user_info"]["mid"], + } + self.set_cookies(cookies) + + return AppLoginResult(**cookies) + + async def _send_verification_email( + self, + ticket: ActionTicket, + *, + mmt_result: typing.Optional[SessionMMTResult] = None, + ) -> typing.Union[None, SessionMMT]: + """Send verification email. + + Returns None if success, SessionMMT data if geetest triggered. + """ + headers = {**auth_utility.EMAIL_SEND_HEADERS} + if mmt_result: + headers["x-rpc-aigis"] = mmt_result.to_aigis_header() + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.SEND_VERIFICATION_CODE_URL.get_url(), + json={ + "action_type": "verify_for_component", + "action_ticket": ticket.verify_str.ticket, + }, + headers=headers, + ) as r: + data = await r.json() + + if data["retcode"] == -3101: + # Captcha triggered + aigis = json.loads(r.headers["x-rpc-aigis"]) + return SessionMMT(**aigis) + + if data["retcode"] != 0: + errors.raise_for_retcode(data) + + return None + + async def _verify_email(self, code: str, ticket: ActionTicket) -> None: + """Verify email.""" + async with aiohttp.ClientSession() as session: + async with session.post( + routes.VERIFY_EMAIL_URL.get_url(), + json={ + "action_type": "verify_for_component", + "action_ticket": ticket.verify_str.ticket, + "email_captcha": code, + "verify_method": 2, + }, + headers=auth_utility.EMAIL_VERIFY_HEADERS, + ) as r: + data = await r.json() + + if data["retcode"] != 0: + errors.raise_for_retcode(data) + + return None + + async def _create_qrcode(self) -> QRCodeCreationResult: + """Create a QR code for login.""" + if self.default_game is None: + raise RuntimeError("No default game set.") + + app_id = constants.APP_IDS[self.default_game][self.region] + device_id = "".join(random.choices(ascii_letters + digits, k=64)) + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.CREATE_QRCODE_URL.get_url(), + json={"app_id": app_id, "device": device_id}, + ) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + url: str = data["data"]["url"] + return QRCodeCreationResult( + app_id=app_id, + ticket=url.split("ticket=")[1], + device_id=device_id, + url=url, + ) + + async def _check_qrcode(self, app_id: str, device_id: str, ticket: str) -> QRCodeCheckResult: + """Check the status of a QR code login.""" + payload = { + "app_id": app_id, + "device": device_id, + "ticket": ticket, + } + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.CHECK_QRCODE_URL.get_url(), + json=payload, + ) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + return QRCodeCheckResult(**data["data"]) diff --git a/genshin/client/components/auth/subclients/game.py b/genshin/client/components/auth/subclients/game.py new file mode 100644 index 00000000..417df2ef --- /dev/null +++ b/genshin/client/components/auth/subclients/game.py @@ -0,0 +1,197 @@ +"""Game sub client for AuthClient. + +Covers OS and CN game auth endpoints. +""" + +import json +import typing + +import aiohttp + +from genshin import constants, errors, types +from genshin.client import routes +from genshin.client.components import base +from genshin.models.auth.cookie import DeviceGrantResult, GameLoginResult +from genshin.models.auth.geetest import RiskyCheckMMT, RiskyCheckMMTResult, RiskyCheckResult +from genshin.models.auth.responses import ShieldLoginResponse +from genshin.utility import auth as auth_utility + +__all__ = ["GameAuthClient"] + + +class GameAuthClient(base.BaseClient): + """Game sub client for AuthClient.""" + + async def _risky_check( + self, action_type: str, api_name: str, *, username: typing.Optional[str] = None + ) -> RiskyCheckResult: + """Check if the given action (endpoint) is risky (whether captcha verification is required).""" + payload = {"action_type": action_type, "api_name": api_name} + if username: + payload["username"] = username + + 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 + ) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + return RiskyCheckResult(**data["data"]) + + @typing.overload + async def _shield_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + mmt_result: RiskyCheckMMTResult, + ) -> ShieldLoginResponse: ... + + @typing.overload + async def _shield_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + mmt_result: None = ..., + ) -> typing.Union[ShieldLoginResponse, RiskyCheckMMT]: ... + + async def _shield_login( + self, + account: str, + password: str, + *, + encrypted: bool = False, + mmt_result: typing.Optional[RiskyCheckMMTResult] = None, + ) -> typing.Union[ShieldLoginResponse, RiskyCheckMMT]: + """Log in with the given account and password. + + Returns MMT if geetest verification is required. + """ + if self.default_game is None: + raise ValueError("No default game set.") + + headers = auth_utility.SHIELD_LOGIN_HEADERS.copy() + if mmt_result: + headers["x-rpc-risky"] = mmt_result.to_rpc_risky() + else: + # Check if geetest is required + check_result = await self._risky_check("login", "/shield/api/login", username=account) + if check_result.mmt: + return check_result.to_mmt() + else: + headers["x-rpc-risky"] = auth_utility.generate_risky_header(check_result.id) + + payload = { + "account": account, + "password": password if encrypted else auth_utility.encrypt_credentials(password, 2), + "is_crypto": True, + } + async with aiohttp.ClientSession() as session: + async with session.post( + routes.SHIELD_LOGIN_URL.get_url(self.region, self.default_game), json=payload, headers=headers + ) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + return ShieldLoginResponse(**data["data"]) + + @typing.overload + async def _send_game_verification_email( # noqa: D102 missing docstring in overload? + self, + action_ticket: str, + *, + mmt_result: RiskyCheckMMTResult, + ) -> None: ... + + @typing.overload + async def _send_game_verification_email( # noqa: D102 missing docstring in overload? + self, + action_ticket: str, + *, + mmt_result: None = ..., + ) -> typing.Union[None, RiskyCheckMMT]: ... + + async def _send_game_verification_email( + self, action_ticket: str, *, 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() + if mmt_result: + headers["x-rpc-risky"] = mmt_result.to_rpc_risky() + else: + # Check if geetest is required + check_result = await self._risky_check("device_grant", "/device/api/preGrantByTicket") + if check_result.mmt: + return check_result.to_mmt() + else: + headers["x-rpc-risky"] = auth_utility.generate_risky_header(check_result.id) + + payload = { + "way": "Way_Email", + "action_ticket": action_ticket, + "device": { + "device_model": "iPhone15,4", + "device_id": auth_utility.DEVICE_ID, + "client": 1, + "device_name": "iPhone", + }, + } + async with aiohttp.ClientSession() as session: + async with session.post( + routes.PRE_GRANT_TICKET_URL.get_url(self.region), json=payload, headers=headers + ) as r: + data = await r.json() + + if data["retcode"] != 0: + errors.raise_for_retcode(data) + + return None + + async def _verify_game_email(self, code: str, action_ticket: str) -> DeviceGrantResult: + """Verify the email code.""" + payload = {"code": code, "ticket": action_ticket} + 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: + data = await r.json() + + return DeviceGrantResult(**data["data"]) + + @base.region_specific(types.Region.OVERSEAS) + async def _os_game_login(self, uid: str, game_token: str) -> GameLoginResult: + """Log in to the game.""" + if self.default_game is None: + raise ValueError("No default game set.") + + payload = { + "channel_id": 1, + "device": 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]) + + 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, + ) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + return GameLoginResult(**data["data"]) diff --git a/genshin/client/components/auth/subclients/web.py b/genshin/client/components/auth/subclients/web.py new file mode 100644 index 00000000..caec8ccd --- /dev/null +++ b/genshin/client/components/auth/subclients/web.py @@ -0,0 +1,232 @@ +"""Web sub client for AuthClient. + +Covers HoYoLAB and Miyoushe web auth endpoints. +""" + +import json +import typing + +import aiohttp + +from genshin import constants, errors, types +from genshin.client import routes +from genshin.client.components import base +from genshin.models.auth.cookie import CNWebLoginResult, MobileLoginResult, WebLoginResult +from genshin.models.auth.geetest import SessionMMT, SessionMMTResult +from genshin.utility import auth as auth_utility +from genshin.utility import ds as ds_utility + +__all__ = ["WebAuthClient"] + + +class WebAuthClient(base.BaseClient): + """Web sub client for AuthClient.""" + + @typing.overload + async def _os_web_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + token_type: typing.Optional[int] = ..., + mmt_result: SessionMMTResult, + ) -> WebLoginResult: ... + + @typing.overload + async def _os_web_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + token_type: typing.Optional[int] = ..., + mmt_result: None = ..., + ) -> typing.Union[SessionMMT, WebLoginResult]: ... + + @base.region_specific(types.Region.OVERSEAS) + async def _os_web_login( + self, + account: str, + password: str, + *, + encrypted: bool = False, + token_type: typing.Optional[int] = 6, + mmt_result: typing.Optional[SessionMMTResult] = None, + ) -> typing.Union[SessionMMT, WebLoginResult]: + """Login with a password using web endpoint. + + Returns either data from aigis header or cookies. + """ + headers = {**auth_utility.WEB_LOGIN_HEADERS} + if mmt_result: + headers["x-rpc-aigis"] = mmt_result.to_aigis_header() + + payload = { + "account": account if encrypted else auth_utility.encrypt_credentials(account, 1), + "password": password if encrypted else auth_utility.encrypt_credentials(password, 1), + "token_type": token_type, + } + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.WEB_LOGIN_URL.get_url(), + json=payload, + headers=headers, + ) as r: + data = await r.json() + cookies = {cookie.key: cookie.value for cookie in r.cookies.values()} + + if data["retcode"] == -3101: + # Captcha triggered + aigis = json.loads(r.headers["x-rpc-aigis"]) + return SessionMMT(**aigis) + + if not data["data"]: + errors.raise_for_retcode(data) + + if data["data"].get("stoken"): + cookies["stoken"] = data["data"]["stoken"] + + self.set_cookies(cookies) + return WebLoginResult(**cookies) + + @typing.overload + async def _cn_web_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + mmt_result: SessionMMTResult, + ) -> CNWebLoginResult: ... + + @typing.overload + async def _cn_web_login( # noqa: D102 missing docstring in overload? + self, + account: str, + password: str, + *, + encrypted: bool = ..., + mmt_result: None = ..., + ) -> typing.Union[SessionMMT, CNWebLoginResult]: ... + + @base.region_specific(types.Region.CHINESE) + async def _cn_web_login( + self, + account: str, + password: str, + *, + encrypted: bool = False, + mmt_result: typing.Optional[SessionMMTResult] = None, + ) -> typing.Union[SessionMMT, CNWebLoginResult]: + """ + Login with account and password using Miyoushe loginByPassword endpoint. + + Returns data from aigis header or cookies. + """ + headers = { + **auth_utility.CN_LOGIN_HEADERS, + "ds": ds_utility.generate_dynamic_secret(constants.DS_SALT["cn_signin"]), + } + if mmt_result: + headers["x-rpc-aigis"] = mmt_result.to_aigis_header() + + payload = { + "account": account if encrypted else auth_utility.encrypt_credentials(account, 2), + "password": password if encrypted else auth_utility.encrypt_credentials(password, 2), + } + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.CN_WEB_LOGIN_URL.get_url(), + json=payload, + headers=headers, + ) as r: + data = await r.json() + + if data["retcode"] == -3102: + # Captcha triggered + aigis = json.loads(r.headers["x-rpc-aigis"]) + return SessionMMT(**aigis) + + if not data["data"]: + errors.raise_for_retcode(data) + + cookies = {cookie.key: cookie.value for cookie in r.cookies.values()} + self.set_cookies(cookies) + + return CNWebLoginResult(**cookies) + + async def _send_mobile_otp( + self, + mobile: str, + *, + encrypted: bool = False, + mmt_result: typing.Optional[SessionMMTResult] = None, + ) -> typing.Union[None, SessionMMT]: + """Attempt to send OTP to the provided mobile number. + + May return aigis headers if captcha is triggered, None otherwise. + """ + headers = { + **auth_utility.CN_LOGIN_HEADERS, + "ds": ds_utility.generate_dynamic_secret(constants.DS_SALT["cn_signin"]), + } + if mmt_result: + headers["x-rpc-aigis"] = mmt_result.to_aigis_header() + + payload = { + "mobile": mobile if encrypted else auth_utility.encrypt_credentials(mobile, 2), + "area_code": auth_utility.encrypt_credentials("+86", 2), + } + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.MOBILE_OTP_URL.get_url(), + json=payload, + headers=headers, + ) as r: + data = await r.json() + + if data["retcode"] == -3101: + # Captcha triggered + aigis = json.loads(r.headers["x-rpc-aigis"]) + return SessionMMT(**aigis) + + if not data["data"]: + errors.raise_for_retcode(data) + + return None + + async def _login_with_mobile_otp(self, mobile: str, otp: str, *, encrypted: bool = False) -> MobileLoginResult: + """Login with OTP and mobile number. + + Returns cookies if OTP matches the one sent, raises an error otherwise. + """ + headers = { + **auth_utility.CN_LOGIN_HEADERS, + "ds": ds_utility.generate_dynamic_secret(constants.DS_SALT["cn_signin"]), + } + + payload = { + "mobile": mobile if encrypted else auth_utility.encrypt_credentials(mobile, 2), + "area_code": auth_utility.encrypt_credentials("+86", 2), + "captcha": otp, + } + + async with aiohttp.ClientSession() as session: + async with session.post( + routes.MOBILE_LOGIN_URL.get_url(), + json=payload, + headers=headers, + ) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + cookies = {cookie.key: cookie.value for cookie in r.cookies.values()} + self.set_cookies(cookies) + + return MobileLoginResult(**cookies) diff --git a/genshin/client/components/base.py b/genshin/client/components/base.py index 49673cf9..d47bc319 100644 --- a/genshin/client/components/base.py +++ b/genshin/client/components/base.py @@ -3,6 +3,7 @@ import abc import asyncio import base64 +import functools import json import logging import os @@ -24,10 +25,25 @@ __all__ = ["BaseClient"] +T = typing.TypeVar("T") +CallableT = typing.TypeVar("CallableT", bound="typing.Callable[..., object]") +AsyncCallableT = typing.TypeVar("AsyncCallableT", bound="typing.Callable[..., typing.Awaitable[object]]") + + class BaseClient(abc.ABC): """Base ABC Client.""" - __slots__ = ("cookie_manager", "cache", "_lang", "_region", "_default_game", "uids", "authkeys", "_hoyolab_id") + __slots__ = ( + "cookie_manager", + "cache", + "_lang", + "_region", + "_default_game", + "uids", + "authkeys", + "_hoyolab_id", + "_accounts", + ) USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36" # noqa: E501 @@ -42,6 +58,7 @@ class BaseClient(abc.ABC): uids: typing.Dict[types.Game, int] authkeys: typing.Dict[types.Game, str] _hoyolab_id: typing.Optional[int] + _accounts: typing.Dict[types.Game, hoyolab_models.GenshinAccount] def __init__( self, @@ -62,6 +79,7 @@ def __init__( self.uids = {} self.authkeys = {} + self._accounts = {} self.default_game = game self.lang = lang @@ -494,6 +512,39 @@ async def _get_uid(self, game: types.Game) -> int: raise errors.AccountNotFound(msg="No UID provided and account has no game account bound to it.") + async def _update_cached_accounts(self) -> None: + """Update cached fallback accounts.""" + mixed_accounts = await self.get_game_accounts() + + game_accounts: typing.Dict[types.Game, typing.List[hoyolab_models.GenshinAccount]] = {} + for account in mixed_accounts: + if not isinstance(account.game, types.Game): # pyright: ignore[reportUnnecessaryIsInstance] + continue + + game_accounts.setdefault(account.game, []).append(account) + + self._accounts = {} + for game, accounts in game_accounts.items(): + self._accounts[game] = next( + (acc for acc in accounts if acc.uid == self.uids.get(game)), max(accounts, key=lambda a: a.level) + ) + + @concurrency.prevent_concurrency + async def _get_account(self, game: types.Game) -> hoyolab_models.GenshinAccount: + """Get a cached fallback account.""" + if (account := self._accounts.get(game)) and (uid := self.uids.get(game)) and account.uid == uid: + return account + + await self._update_cached_accounts() + + if account := self._accounts.get(game): + if (uid := self.uids.get(game)) and account.uid != uid: + raise errors.AccountNotFound(msg="There is no game account with such UID.") + + return account + + raise errors.AccountNotFound(msg="Account has no game account bound to it.") + def _get_hoyolab_id(self) -> int: """Get a cached fallback hoyolab ID.""" if self.hoyolab_id is not None: @@ -534,3 +585,22 @@ async def update_mi18n(self, langs: typing.Iterable[str] = constants.LANGS, *, f coros.append(self._fetch_mi18n(key, lang, force=force)) await asyncio.gather(*coros) + + +def region_specific(region: types.Region) -> typing.Callable[[AsyncCallableT], AsyncCallableT]: + """Prevent function to be ran with unsupported regions.""" + + def decorator(func: AsyncCallableT) -> AsyncCallableT: + @functools.wraps(func) + async def wrapper(self: typing.Any, *args: typing.Any, **kwargs: typing.Any) -> typing.Any: + + if not hasattr(self, "region"): + raise TypeError("Cannot use @region_specific on a plain function.") + if region != self.region: + raise RuntimeError("The method can only be used with client region set to " + region) + + return await func(self, *args, **kwargs) + + return typing.cast("AsyncCallableT", wrapper) + + return decorator diff --git a/genshin/client/components/daily.py b/genshin/client/components/daily.py index 53981d29..f9d51800 100644 --- a/genshin/client/components/daily.py +++ b/genshin/client/components/daily.py @@ -8,7 +8,7 @@ import aiohttp.typedefs -from genshin import constants, paginators, types, utility +from genshin import constants, paginators, types from genshin.client import cache, routes from genshin.client.components import base from genshin.client.manager import managers @@ -54,23 +54,22 @@ async def request_daily_reward( headers["referer"] = "https://act.hoyolab.com/" elif self.region == types.Region.CHINESE: - uid = await self._get_uid(game) + account = await self._get_account(game) - params["uid"] = uid - params["region"] = utility.recognize_server(uid, game) + params["uid"] = account.uid + params["region"] = account.server - # most of the extra headers are likely just placebo - headers["x-rpc-app_version"] = "2.34.1" + # These headers are optional but left here because they might affect geetest trigger rate + headers["x-rpc-app_version"] = "2.70.1" headers["x-rpc-client_type"] = "5" headers["x-rpc-device_id"] = str(uuid.uuid4()) headers["x-rpc-sys_version"] = "12" headers["x-rpc-platform"] = "android" headers["x-rpc-channel"] = "miyousheluodi" headers["x-rpc-device_model"] = str(self.hoyolab_id) or "" - headers["referer"] = ( - "https://webstatic.mihoyo.com/bbs/event/signin-ys/index.html?" - "bbs_auth_required=true&act_id=e202009291139501&utm_source=bbs&utm_medium=mys&utm_campaign=icon" - ) + + if game == types.Game.GENSHIN: + headers["x-rpc-signgame"] = "hk4e" headers["ds"] = ds_utility.generate_dynamic_secret(constants.DS_SALT["cn_signin"]) diff --git a/genshin/client/components/gacha.py b/genshin/client/components/gacha.py index fe6cad62..19fc90b0 100644 --- a/genshin/client/components/gacha.py +++ b/genshin/client/components/gacha.py @@ -222,7 +222,7 @@ async def _get_banner_details( server = "prod_official_asia" if game == types.Game.STARRAIL else "os_asia" data = await self.request_webstatic( - f"/{region}/gacha_info/{server}/{banner_id}/{lang}.json", + f"/gacha_info/{region}/{server}/{banner_id}/{lang}.json", cache=client_cache.cache_key("banner", endpoint="details", banner=banner_id, lang=lang), ) return models.BannerDetails(**data, banner_id=banner_id) @@ -240,12 +240,19 @@ async def get_genshin_banner_ids(self) -> typing.Sequence[str]: Uses the current cn banners. """ + + def process_gacha(data: typing.Mapping[str, typing.Any]) -> str: + # Temporary fix for 4.5 chronicled wish + if data["gacha_type"] == 500: + return "8b10b48c52dd6870f92d72e9963b44bb8968ed2f" + return data["gacha_id"] + data = await self.request_webstatic( - "hk4e/gacha_info/cn_gf01/gacha/list.json", + "gacha_info/hk4e/cn_gf01/gacha/list.json", region=types.Region.CHINESE, cache=client_cache.cache_key("banner", endpoint="ids"), ) - return [i["gacha_id"] for i in data["data"]["list"]] + return list(map(process_gacha, data["data"]["list"])) async def get_banner_details( self, diff --git a/genshin/client/components/geetest/__init__.py b/genshin/client/components/geetest/__init__.py deleted file mode 100644 index 3871455b..00000000 --- a/genshin/client/components/geetest/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Geetest captcha handler. - -Credits to M-307 - https://github.com/mrwan200 -""" - -from .client import * diff --git a/genshin/client/components/geetest/client.py b/genshin/client/components/geetest/client.py deleted file mode 100644 index 036fa4b4..00000000 --- a/genshin/client/components/geetest/client.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Geetest client component.""" - -import json -import typing - -import aiohttp -import aiohttp.web - -from genshin import constants, errors -from genshin.client import routes -from genshin.client.components import base -from genshin.utility import ds as ds_utility -from genshin.utility import geetest as geetest_utility - -from . import server - -__all__ = ["GeetestClient"] - - -class GeetestClient(base.BaseClient): - """Geetest client component.""" - - async def web_login( - self, - account: str, - password: str, - *, - tokenType: typing.Optional[int] = 6, - geetest: typing.Optional[typing.Dict[str, typing.Any]] = None, - ) -> typing.Dict[str, typing.Any]: - """Login with a password using web endpoint. - - Returns either data from aigis header or cookies. - """ - headers = {**geetest_utility.WEB_LOGIN_HEADERS} - if geetest: - mmt_data = geetest["data"] - session_id = geetest["session_id"] - headers["x-rpc-aigis"] = geetest_utility.get_aigis_header(session_id, mmt_data) - - payload = { - "account": geetest_utility.encrypt_geetest_credentials(account), - "password": geetest_utility.encrypt_geetest_credentials(password), - "token_type": tokenType, - } - - async with aiohttp.ClientSession() as session: - async with session.post( - routes.WEB_LOGIN_URL.get_url(), - json=payload, - headers=headers, - ) as r: - data = await r.json() - cookies = {cookie.key: cookie.value for cookie in r.cookies.values()} - - if data["retcode"] == -3101: - # Captcha triggered - aigis = json.loads(r.headers["x-rpc-aigis"]) - aigis["data"] = json.loads(aigis["data"]) - return aigis - - if not data["data"]: - errors.raise_for_retcode(data) - - if data["data"].get("stoken"): - cookies["stoken"] = data["data"]["stoken"] - - self.set_cookies(cookies) - - return cookies - - async def app_login( - self, - account: str, - password: str, - *, - geetest: typing.Optional[typing.Dict[str, typing.Any]] = None, - ) -> typing.Dict[str, typing.Any]: - """Login with a password using HoYoLab app endpoint. - - Returns data from aigis header or action_ticket or cookies. - """ - headers = { - **geetest_utility.APP_LOGIN_HEADERS, - "ds": ds_utility.generate_dynamic_secret(constants.DS_SALT["app_login"]), - } - if geetest: - mmt_data = geetest["data"] - session_id = geetest["session_id"] - headers["x-rpc-aigis"] = geetest_utility.get_aigis_header(session_id, mmt_data) - - payload = { - "account": geetest_utility.encrypt_geetest_credentials(account), - "password": geetest_utility.encrypt_geetest_credentials(password), - } - - async with aiohttp.ClientSession() as session: - async with session.post( - routes.APP_LOGIN_URL.get_url(), - json=payload, - headers=headers, - ) as r: - data = await r.json() - - if data["retcode"] == -3101: - # Captcha triggered - aigis = json.loads(r.headers["x-rpc-aigis"]) - aigis["data"] = json.loads(aigis["data"]) - return aigis - - if data["retcode"] == -3239: - # Email verification required - return json.loads(r.headers["x-rpc-verify"]) - - if not data["data"]: - errors.raise_for_retcode(data) - - cookies = { - "stoken": data["data"]["token"]["token"], - "ltuid_v2": data["data"]["user_info"]["aid"], - "ltmid_v2": data["data"]["user_info"]["mid"], - "account_id_v2": data["data"]["user_info"]["aid"], - "account_mid_v2": data["data"]["user_info"]["mid"], - } - self.set_cookies(cookies) - - return cookies - - async def send_verification_email( - self, - ticket: typing.Dict[str, typing.Any], - *, - geetest: typing.Optional[typing.Dict[str, typing.Any]] = None, - ) -> typing.Union[None, typing.Dict[str, typing.Any]]: - """Send verification email. - - Returns None if success, aigis headers (mmt/aigis) otherwise. - """ - headers = {**geetest_utility.EMAIL_SEND_HEADERS} - if geetest: - mmt_data = geetest["data"] - session_id = geetest["session_id"] - headers["x-rpc-aigis"] = geetest_utility.get_aigis_header(session_id, mmt_data) - - async with aiohttp.ClientSession() as session: - async with session.post( - routes.SEND_VERIFICATION_CODE_URL.get_url(), - json={ - "action_type": "verify_for_component", - "action_ticket": ticket["verify_str"]["ticket"], - }, - headers=headers, - ) as r: - data = await r.json() - - if data["retcode"] == -3101: - # Captcha triggered - aigis = json.loads(r.headers["x-rpc-aigis"]) - aigis["data"] = json.loads(aigis["data"]) - return aigis - - if data["retcode"] != 0: - errors.raise_for_retcode(data) - - return None - - async def verify_email(self, code: str, ticket: typing.Dict[str, typing.Any]) -> None: - """Verify email.""" - async with aiohttp.ClientSession() as session: - async with session.post( - routes.VERIFY_EMAIL_URL.get_url(), - json={ - "action_type": "verify_for_component", - "action_ticket": ticket["verify_str"]["ticket"], - "email_captcha": code, - "verify_method": 2, - }, - headers=geetest_utility.EMAIL_VERIFY_HEADERS, - ) as r: - data = await r.json() - - if data["retcode"] != 0: - errors.raise_for_retcode(data) - - return None - - async def login_with_password( - self, - account: str, - password: str, - *, - port: int = 5000, - tokenType: typing.Optional[int] = 6, - geetest_solver: typing.Optional[ - typing.Callable[[typing.Dict[str, typing.Any]], typing.Awaitable[typing.Dict[str, typing.Any]]] - ] = None, - ) -> typing.Dict[str, str]: - """Login with a password via web endpoint. - - Note that this will start a webserver if captcha is - triggered and `geetest_solver` is not passed. - """ - result = await self.web_login(account, password, tokenType=tokenType) - - if "session_id" not in result: - # Captcha not triggered - return result - - if geetest_solver: - geetest = await geetest_solver(result) - else: - geetest = await server.solve_geetest(result, port=port) - - return await self.web_login(account, password, tokenType=tokenType, geetest=geetest) - - async def login_with_app_password( - self, - account: str, - password: str, - *, - port: int = 5000, - geetest_solver: typing.Optional[ - typing.Callable[[typing.Dict[str, typing.Any]], typing.Awaitable[typing.Dict[str, typing.Any]]] - ] = None, - ) -> typing.Dict[str, str]: - """Login with a password via HoYoLab app endpoint. - - Note that this will start a webserver if either of the - following happens: - - 1. Captcha is triggered and `geetest_solver` is not passed. - 2. Email verification is triggered (can happen if you - first login with a new device). - """ - result = await self.app_login(account, password) - - if "session_id" in result: - # Captcha triggered - if geetest_solver: - geetest = await geetest_solver(result) - else: - geetest = await server.solve_geetest(result, port=port) - - result = await self.app_login(account, password, geetest=geetest) - - if "risk_ticket" in result: - # Email verification required - mmt = await self.send_verification_email(result) - if mmt: - if geetest_solver: - geetest = await geetest_solver(mmt) - else: - geetest = await server.solve_geetest(mmt, port=port) - - await server.verify_email(self, result, port=port) - result = await self.app_login(account, password) - - return result diff --git a/genshin/client/components/geetest/server.py b/genshin/client/components/geetest/server.py deleted file mode 100644 index a8bb641b..00000000 --- a/genshin/client/components/geetest/server.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Aiohttp webserver used for captcha solving and email verification.""" - -from __future__ import annotations - -import asyncio -import typing -import webbrowser - -import aiohttp -from aiohttp import web - -from . import client - -__all__ = ["get_page", "launch_webapp", "solve_geetest", "verify_email"] - - -def get_page(page: typing.Literal["captcha", "verify-email"]) -> str: - """Get the HTML page.""" - return ( - """ - - - - - - - """ - if page == "captcha" - else """ - - - - - - - - - """ - ) - - -GT_URL = "https://raw.githubusercontent.com/GeeTeam/gt3-node-sdk/master/demo/static/libs/gt.js" - - -async def launch_webapp( - page: typing.Literal["captcha", "verify-email"], - *, - port: int = 5000, - mmt: typing.Optional[typing.Dict[str, typing.Any]] = None, -) -> typing.Any: - """Create and run a webapp to solve captcha or send verification code.""" - routes = web.RouteTableDef() - future: asyncio.Future[typing.Any] = asyncio.Future() - - @routes.get("/captcha") - async def captcha(request: web.Request) -> web.StreamResponse: - return web.Response(body=get_page("captcha"), content_type="text/html") - - @routes.get("/verify-email") - async def verify_email(request: web.Request) -> web.StreamResponse: - return web.Response(body=get_page("verify-email"), content_type="text/html") - - @routes.get("/gt.js") - async def gt(request: web.Request) -> web.StreamResponse: - async with aiohttp.ClientSession() as session: - r = await session.get(GT_URL) - content = await r.read() - - return web.Response(body=content, content_type="text/javascript") - - @routes.get("/mmt") - async def mmt_endpoint(request: web.Request) -> web.Response: - return web.json_response(mmt) - - @routes.post("/send-data") - async def send_data_endpoint(request: web.Request) -> web.Response: - body = await request.json() - future.set_result(body) - - return web.Response(status=204) - - app = web.Application() - app.add_routes(routes) - - runner = web.AppRunner(app) - await runner.setup() - - site = web.TCPSite(runner, host="localhost", port=port) - print(f"Opening http://localhost:{port}/{page} in browser...") # noqa - webbrowser.open_new_tab(f"http://localhost:{port}/{page}") - - await site.start() - - try: - data = await future - finally: - await asyncio.sleep(0.3) - await runner.shutdown() - - return data - - -async def solve_geetest( - mmt: typing.Dict[str, typing.Any], - *, - port: int = 5000, -) -> typing.Dict[str, typing.Any]: - """Solve a geetest captcha manually.""" - return await launch_webapp("captcha", port=port, mmt=mmt) - - -async def verify_email( - client: client.GeetestClient, - ticket: typing.Dict[str, typing.Any], - *, - port: int = 5000, -) -> None: - """Verify email to login via HoYoLab app endpoint.""" - data = await launch_webapp("verify-email", port=port) - code = data["code"] - - return await client.verify_email(code, ticket) diff --git a/genshin/client/components/transaction.py b/genshin/client/components/transaction.py index 73dcd9cb..d9e37af7 100644 --- a/genshin/client/components/transaction.py +++ b/genshin/client/components/transaction.py @@ -64,7 +64,7 @@ async def _get_transaction_page( transactions: typing.List[models.BaseTransaction] = [] for trans in data["list"]: model = models.ItemTransaction if "name" in trans else models.Transaction - model = typing.cast("type[models.BaseTransaction]", model) + model = typing.cast("typing.Type[models.BaseTransaction]", model) transactions.append(model(**trans, kind=kind, lang=lang or self.lang)) return transactions diff --git a/genshin/client/manager/cookie.py b/genshin/client/manager/cookie.py index a2b59d0c..ef3af82e 100644 --- a/genshin/client/manager/cookie.py +++ b/genshin/client/manager/cookie.py @@ -1,6 +1,6 @@ """Cookie completion. -Available convertions: +Available conversions: - fetch_cookie_with_cookie - cookie_token -> cookie_token @@ -14,11 +14,18 @@ - fetch_cookie_with_stoken_v2 - stoken (v2) + mid -> ltoken_v2 (token_type=2) - stoken (v2) + mid -> cookie_token_v2 (token_type=4) +- fetch_cookie_token_with_game_token + - game_token -> cookie_token +- fetch_stoken_with_game_token + - game_token -> stoken """ from __future__ import annotations +import random import typing +import uuid +from string import ascii_letters, digits import aiohttp import aiohttp.typedefs @@ -26,13 +33,16 @@ from genshin import constants, errors, types from genshin.client import routes from genshin.client.manager import managers +from genshin.models.auth.cookie import StokenResult from genshin.utility import ds as ds_utility __all__ = [ "complete_cookies", "fetch_cookie_token_info", + "fetch_cookie_token_with_game_token", "fetch_cookie_with_cookie", "fetch_cookie_with_stoken_v2", + "fetch_stoken_with_game_token", "refresh_cookie_token", ] @@ -166,3 +176,45 @@ async def complete_cookies( cookies = await refresh_cookie_token(cookies, region=region) # type: ignore[assignment] return cookies + + +async def fetch_cookie_token_with_game_token(*, game_token: str, account_id: str) -> str: + """Fetch cookie token with game token, which can be obtained by scanning a QR code.""" + url = routes.GET_COOKIE_TOKEN_BY_GAME_TOKEN_URL.get_url() + params = { + "game_token": game_token, + "account_id": account_id, + } + + async with aiohttp.ClientSession() as session: + async with session.get(url, params=params) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + return data["data"]["cookie_token"] + + +async def fetch_stoken_with_game_token(*, game_token: str, account_id: int) -> StokenResult: + """Fetch cookie token with game token, which can be obtained by scanning a QR code.""" + url = routes.GET_STOKEN_BY_GAME_TOKEN_URL.get_url() + payload = { + "account_id": account_id, + "game_token": game_token, + } + headers = { + "DS": ds_utility.generate_passport_ds(body=payload), + "x-rpc-device_id": uuid.uuid4().hex, + "x-rpc-device_fp": "".join(random.choices(ascii_letters + digits, k=13)), + "x-rpc-app_id": "bll8iq97cem8", + } + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=payload, headers=headers) as r: + data = await r.json() + + if not data["data"]: + errors.raise_for_retcode(data) + + return StokenResult(**data["data"]) diff --git a/genshin/client/routes.py b/genshin/client/routes.py index 5a26f3fd..6b69ce83 100644 --- a/genshin/client/routes.py +++ b/genshin/client/routes.py @@ -13,24 +13,31 @@ "BBS_REFERER_URL", "BBS_URL", "CALCULATOR_URL", + "CHECK_QRCODE_URL", + "CN_WEB_LOGIN_URL", "COMMUNITY_URL", "COOKIE_V2_REFRESH_URL", + "CREATE_MMT_URL", + "CREATE_QRCODE_URL", "DETAIL_LEDGER_URL", "GACHA_URL", + "GET_COOKIE_TOKEN_BY_GAME_TOKEN_URL", + "GET_STOKEN_BY_GAME_TOKEN_URL", "HK4E_URL", "INFO_LEDGER_URL", "LINEUP_URL", "MI18N", "RECORD_URL", "REWARD_URL", - "Route", "TAKUMI_URL", "TEAPOT_URL", "VERIFY_EMAIL_URL", + "VERIFY_MMT_URL", "WEBAPI_URL", "WEBSTATIC_URL", "WEB_LOGIN_URL", "YSULOG_URL", + "Route", ] @@ -97,8 +104,8 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: WEBSTATIC_URL = InternationalRoute( - "https://webstatic-sea.hoyoverse.com/", - "https://webstatic.mihoyo.com/", + "https://operation-webstatic.hoyoverse.com/", + "https://operation-webstatic.mihoyo.com/", ) WEBAPI_URL = InternationalRoute( @@ -181,8 +188,8 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: tot_tw="https://sg-public-api.hoyolab.com/event/luna/os?act_id=e202308141137581", ), chinese=dict( - genshin="https://api-takumi.mihoyo.com/event/bbs_sign_reward/?act_id=e202009291139501", - honkai3rd="https://api-takumi.mihoyo.com/event/luna/?act_id=e202207181446311", + 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", tot="https://api-takumi.mihoyo.com/event/luna/?act_id=e202202251749321", ), @@ -198,7 +205,7 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: GACHA_URL = GameRoute( overseas=dict( - genshin="https://hk4e-api-os.hoyoverse.com/event/gacha_info/api/", + genshin="https://hk4e-api-os.hoyoverse.com/gacha_info/api/", hkrpg="https://api-os-takumi.mihoyo.com/common/gacha_record/api/", ), chinese=dict( @@ -212,16 +219,78 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL: ) MI18N = dict( - bbs="https://webstatic-sea.mihoyo.com/admin/mi18n/bbs_cn/m11241040191111/m11241040191111-{lang}.json", + bbs="https://fastcdn.hoyoverse.com/mi18n/bbs_oversea/m11241040191111/m11241040191111-{lang}.json", inquiry="https://mi18n-os.hoyoverse.com/webstatic/admin/mi18n/hk4e_global/m02251421001311/m02251421001311-{lang}.json", ) COOKIE_V2_REFRESH_URL = Route("https://sg-public-api.hoyoverse.com/account/ma-passport/token/getBySToken") +GET_COOKIE_TOKEN_BY_GAME_TOKEN_URL = Route("https://api-takumi.mihoyo.com/auth/api/getCookieAccountInfoByGameToken") +GET_STOKEN_BY_GAME_TOKEN_URL = Route("https://passport-api.mihoyo.com/account/ma-cn-session/app/getTokenByGameToken") WEB_LOGIN_URL = Route("https://sg-public-api.hoyolab.com/account/ma-passport/api/webLoginByPassword") APP_LOGIN_URL = Route("https://sg-public-api.hoyoverse.com/account/ma-passport/api/appLoginByPassword") +CN_WEB_LOGIN_URL = Route("https://passport-api.miyoushe.com/account/ma-cn-passport/web/loginByPassword") SEND_VERIFICATION_CODE_URL = Route( "https://sg-public-api.hoyoverse.com/account/ma-verifier/api/createEmailCaptchaByActionTicket" ) VERIFY_EMAIL_URL = Route("https://sg-public-api.hoyoverse.com/account/ma-verifier/api/verifyActionTicketPartly") + +CHECK_MOBILE_VALIDITY_URL = Route("https://webapi.account.mihoyo.com/Api/is_mobile_registrable") +MOBILE_OTP_URL = Route("https://passport-api.miyoushe.com/account/ma-cn-verifier/verifier/createLoginCaptcha") +MOBILE_LOGIN_URL = Route("https://passport-api.miyoushe.com/account/ma-cn-passport/web/loginByMobileCaptcha") + +CREATE_QRCODE_URL = Route("https://hk4e-sdk.mihoyo.com/hk4e_cn/combo/panda/qrcode/fetch") +CHECK_QRCODE_URL = Route("https://hk4e-sdk.mihoyo.com/hk4e_cn/combo/panda/qrcode/query") + +CREATE_MMT_URL = InternationalRoute( + overseas="https://sg-public-api.hoyolab.com/event/toolcomsrv/risk/createGeetest?is_high=true", + chinese="https://api-takumi-record.mihoyo.com/game_record/app/card/wapi/createVerification?is_high=false", +) + +VERIFY_MMT_URL = Route("https://sg-public-api.hoyolab.com/event/toolcomsrv/risk/verifyGeetest") + +GAME_RISKY_CHECK_URL = InternationalRoute( + overseas="https://api-account-os.hoyoverse.com/account/risky/api/check", + chinese="https://gameapi-account.mihoyo.com/account/risky/api/check", +) + +SHIELD_LOGIN_URL = GameRoute( + overseas=dict( + genshin="https://hk4e-sdk-os.hoyoverse.com/hk4e_global/mdk/shield/api/login", + honkai3rd="https://bh3-sdk-os.hoyoverse.com/bh3_os/mdk/shield/api/login", + hkrpg="https://hkrpg-sdk-os.hoyoverse.com/hkrpg_global/mdk/shield/api/login", + nap="https://nap-sdk-os.hoyoverse.com/nap_global/mdk/shield/api/login", + ), + chinese=dict( + genshin="https://hk4e-sdk.mihoyo.com/hk4e_cn/mdk/shield/api/login", + honkai3rd="https://api-sdk.mihoyo.com/bh3_cn/mdk/shield/api/login", + hkrpg="https://hkrpg-sdk.mihoyo.com/hkrpg_cn/mdk/shield/api/login", + nap="https://nap-sdk.mihoyo.com/nap_cn/mdk/shield/api/login", + ), +) + +PRE_GRANT_TICKET_URL = InternationalRoute( + overseas="https://api-account-os.hoyoverse.com/account/device/api/preGrantByTicket", + chinese="https://gameapi-account.mihoyo.com/account/device/api/preGrantByTicket", +) + +DEVICE_GRANT_URL = InternationalRoute( + overseas="https://api-account-os.hoyoverse.com/account/device/api/grant", + chinese="https://gameapi-account.mihoyo.com/account/device/api/grant", +) + +GAME_LOGIN_URL = GameRoute( + overseas=dict( + genshin="https://hk4e-sdk-os.hoyoverse.com/hk4e_global/combo/granter/login/v2/login", + honkai3rd="https://bh3-sdk-os.hoyoverse.com/bh3_os/combo/granter/login/v2/login", + hkrpg="https://hkrpg-sdk-os.hoyoverse.com/hkrpg_global/combo/granter/login/v2/login", + nap="https://nap-sdk-os.hoyoverse.com/nap_global/combo/granter/login/v2/login", + ), + chinese=dict( + genshin="https://hk4e-sdk.mihoyo.com/hk4e_cn/combo/granter/login/v2/login", + honkai3rd="https://api-sdk.mihoyo.com/bh3_cn/combo/granter/login/v2/login", + hkrpg="https://hkrpg-sdk.mihoyo.com/hkrpg_cn/combo/granter/login/v2/login", + nap="https://nap-sdk.mihoyo.com/nap_cn/combo/granter/login/v2/login", + ), +) diff --git a/genshin/constants.py b/genshin/constants.py index 2d86a6ff..340bc61f 100644 --- a/genshin/constants.py +++ b/genshin/constants.py @@ -2,7 +2,7 @@ from . import types -__all__ = ["LANGS"] +__all__ = ["APP_IDS", "APP_KEYS", "DS_SALT", "GEETEST_RETCODES", "LANGS"] LANGS = { @@ -28,6 +28,58 @@ types.Region.OVERSEAS: "6s25p5ox5y14umn1p61aqyyvbvvl3lrt", types.Region.CHINESE: "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs", "app_login": "IZPgfb0dRPtBeLuFkdDznSZ6f4wWt6y2", - "cn_signin": "9nQiU3AV0rJSIBWgdynfoGMGKaklfbM7", + "cn_signin": "LyD1rXqMv2GJhnwdvCBjFOKGiKuLY3aO", + "cn_passport": "JwYDpKvLj6MrMqqYU6jTKF17KNO2PXoS", } """Dynamic Secret Salts.""" + +GEETEST_RETCODES = {10035, 5003, 10041, 1034} +"""API error codes that indicate a Geetest was triggered during the API request.""" + +APP_KEYS = { + types.Game.GENSHIN: { + types.Region.OVERSEAS: "6a4c78fe0356ba4673b8071127b28123", + types.Region.CHINESE: "d0d3a7342df2026a70f650b907800111", + }, + types.Game.STARRAIL: { + types.Region.OVERSEAS: "d74818dabd4182d4fbac7f8df1622648", + types.Region.CHINESE: "4650f3a396d34d576c3d65df26415394", + }, + types.Game.HONKAI: { + types.Region.OVERSEAS: "243187699ab762b682a2a2e50ba02285", + types.Region.CHINESE: "0ebc517adb1b62c6b408df153331f9aa", + }, + types.Game.ZZZ: { + types.Region.OVERSEAS: "ff0f2776bf515d79d1f8ff1fb98b2a06", + types.Region.CHINESE: "4650f3a396d34d576c3d65df26415394", + }, +} +"""App keys used for game login.""" + +APP_IDS = { + types.Game.GENSHIN: { + types.Region.OVERSEAS: "4", + types.Region.CHINESE: "4", + }, + types.Game.STARRAIL: { + types.Region.OVERSEAS: "11", + types.Region.CHINESE: "8", + }, + types.Game.HONKAI: { + types.Region.OVERSEAS: "8", + types.Region.CHINESE: "1", + }, + types.Game.ZZZ: { + types.Region.OVERSEAS: "15", + types.Region.CHINESE: "12", + }, +} +"""App IDs used for game login.""" + +GEETEST_RECORD_KEYS = { + types.Game.GENSHIN: "hk4e_game_record", + types.Game.STARRAIL: "hkrpg_game_record", + types.Game.HONKAI: "bh3_game_record", + types.Game.ZZZ: "nap_game_record", +} +"""Keys used to submit geetest result.""" diff --git a/genshin/errors.py b/genshin/errors.py index 1ea5e1e2..acda773f 100644 --- a/genshin/errors.py +++ b/genshin/errors.py @@ -2,15 +2,18 @@ import typing +from genshin.constants import GEETEST_RETCODES + __all__ = [ + "ERRORS", "AccountNotFound", "AlreadyClaimed", "AuthkeyException", "AuthkeyTimeout", "CookieException", + "DailyGeetestTriggered", "DataNotPublic", - "ERRORS", - "GeetestTriggered", + "GeetestError", "GenshinException", "InvalidAuthkey", "InvalidCookies", @@ -31,7 +34,11 @@ class GenshinException(Exception): original: str = "" msg: str = "" - def __init__(self, response: typing.Mapping[str, typing.Any] = {}, msg: typing.Optional[str] = None) -> None: + def __init__( + self, + response: typing.Mapping[str, typing.Any] = {}, + msg: typing.Optional[str] = None, + ) -> None: self.retcode = response.get("retcode", self.retcode) self.original = response.get("message", "") self.msg = msg or self.msg or self.original @@ -109,8 +116,8 @@ class AlreadyClaimed(GenshinException): msg = "Already claimed the daily reward today." -class GeetestTriggered(GenshinException): - """Geetest triggered.""" +class DailyGeetestTriggered(GenshinException): + """Geetest triggered during daily reward claim.""" msg = "Geetest triggered during daily reward claim." @@ -176,6 +183,59 @@ class AccountHasLocked(GenshinException): msg = "Account has been locked because exceeded password limit. Please wait 20 minute and try again" +class WrongOTP(GenshinException): + """Wrong OTP code.""" + + msg = "The provided OTP code is wrong." + + +class GeetestError(GenshinException): + """Geetest triggered during the battle chronicle API request.""" + + def __init__(self, response: typing.Dict[str, typing.Any]) -> None: + super().__init__(response) + + msg = "Geetest triggered during the battle chronicle API request." + + +class OTPRateLimited(GenshinException): + """Too many OTP messages sent for the number. + + The limit is 40 messages/day/number. + """ + + retcode = -119 + msg = "Too many OTP messages sent for the number." + + +class IncorrectGameAccount(GenshinException): + """Game account is incorrect.""" + + retcode = -216 + msg = "Game account is incorrect." + + +class IncorrectGamePassword(GenshinException): + """Game password is incorrect.""" + + retcode = -202 + msg = "Game password is incorrect." + + +class AccountDoesNotExist(GenshinException): + """Account does not exist.""" + + retcode = -3203 + msg = "Account does not exist." + + +class VerificationCodeRateLimited(GenshinException): + """Too many verification code requests for the account.""" + + retcode = -3206 + msg = "Too many verification code requests for the account." + + _TGE = typing.Type[GenshinException] _errors: typing.Dict[int, typing.Union[_TGE, str, typing.Tuple[_TGE, typing.Optional[str]]]] = { # misc hoyolab @@ -189,7 +249,10 @@ class AccountHasLocked(GenshinException): # database game record 10101: TooManyRequests, 10102: DataNotPublic, - 10103: (InvalidCookies, "Cookies are valid but do not have a hoyolab account bound to them."), + 10103: ( + InvalidCookies, + "Cookies are valid but do not have a hoyolab account bound to them.", + ), 10104: "Cannot view real-time notes of other users.", # calculator -500001: "Invalid fields in calculation.", @@ -210,7 +273,10 @@ class AccountHasLocked(GenshinException): -2016: RedemptionCooldown, -2017: RedemptionClaimed, -2018: RedemptionClaimed, - -2021: (RedemptionException, "Cannot claim codes for accounts with adventure rank lower than 10."), + -2021: ( + RedemptionException, + "Cannot claim codes for accounts with adventure rank lower than 10.", + ), # rewards -5003: AlreadyClaimed, # chinese @@ -219,6 +285,15 @@ class AccountHasLocked(GenshinException): # account -3208: AccountLoginFail, -3202: AccountHasLocked, + -3203: AccountDoesNotExist, + -3205: WrongOTP, + -3206: VerificationCodeRateLimited, + # Miyoushe + -119: OTPRateLimited, + -3006: "Request too frequent.", # OTP endpoint + # Game login + -216: IncorrectGameAccount, + -202: IncorrectGamePassword, } ERRORS: typing.Dict[int, typing.Tuple[_TGE, typing.Optional[str]]] = { @@ -262,12 +337,15 @@ def raise_for_retcode(data: typing.Dict[str, typing.Any]) -> typing.NoReturn: raise GenshinException(data) -def check_for_geetest(response: typing.Dict[str, typing.Any]) -> None: - """Check if geetest was triggered and raise an error.""" - if not response.get("data"): # if is an error +def check_for_geetest(data: typing.Dict[str, typing.Any]) -> None: + """Check if geetest was triggered during the request and raise an error if so.""" + if data["retcode"] in GEETEST_RETCODES: + raise GeetestError(data) + + if not data.get("data"): # if is an error return - gt_result = response["data"].get("gt_result", response["data"]) + gt_result = data["data"].get("gt_result", data["data"]) if ( gt_result.get("risk_code") != 0 @@ -275,4 +353,4 @@ def check_for_geetest(response: typing.Dict[str, typing.Any]) -> None: and gt_result.get("challenge") and gt_result.get("success") != 0 ): - raise GeetestTriggered(response, gt=gt_result.get("gt"), challenge=gt_result.get("challenge")) + raise DailyGeetestTriggered(data, gt=gt_result.get("gt"), challenge=gt_result.get("challenge")) diff --git a/genshin/models/__init__.py b/genshin/models/__init__.py index 35edc5e7..b515d70e 100644 --- a/genshin/models/__init__.py +++ b/genshin/models/__init__.py @@ -1,5 +1,6 @@ """API models.""" +from .auth import * from .genshin import * from .honkai import * from .hoyolab import * diff --git a/genshin/models/auth/__init__.py b/genshin/models/auth/__init__.py new file mode 100644 index 00000000..53f1a208 --- /dev/null +++ b/genshin/models/auth/__init__.py @@ -0,0 +1,6 @@ +"""Auth-related models.""" + +from .cookie import * +from .geetest import * +from .qrcode import * +from .verification import * diff --git a/genshin/models/auth/cookie.py b/genshin/models/auth/cookie.py new file mode 100644 index 00000000..05381e58 --- /dev/null +++ b/genshin/models/auth/cookie.py @@ -0,0 +1,144 @@ +"""Cookie-related models""" + +import typing + +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +__all__ = [ + "AppLoginResult", + "CNWebLoginResult", + "CookieLoginResult", + "DeviceGrantResult", + "DeviceGrantResult", + "GameLoginResult", + "MobileLoginResult", + "QRLoginResult", + "StokenResult", + "WebLoginResult", +] + + +class StokenResult(pydantic.BaseModel): + """Result of fetching `stoken` with `fetch_stoken_by_game_token`.""" + + aid: str + mid: str + token: str + + @pydantic.root_validator(pre=True) + def _transform_result(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + return { + "aid": values["user_info"]["aid"], + "mid": values["user_info"]["mid"], + "token": values["token"]["token"], + } + + +class CookieLoginResult(pydantic.BaseModel): + """Base model for cookie login result.""" + + def to_str(self) -> str: + """Convert the login cookies to a string.""" + return "; ".join(f"{key}={value}" for key, value in self.dict().items()) + + def to_dict(self) -> typing.Dict[str, str]: + """Convert the login cookies to a dictionary.""" + return self.dict() + + +class QRLoginResult(CookieLoginResult): + """QR code login cookies. + + Returned by `client.login_with_qrcode`. + """ + + stoken_v2: str + account_id: str + ltuid: str + ltmid: str + cookie_token: str + + +class AppLoginResult(CookieLoginResult): + """App login cookies. + + Returned by `client.login_with_app_password`. + """ + + stoken: str + ltuid_v2: str + ltmid_v2: str + account_id_v2: str + account_mid_v2: str + + +class WebLoginResult(CookieLoginResult): + """Web login cookies. + + Returned by `client.login_with_password`. + """ + + cookie_token_v2: str + account_mid_v2: str + account_id_v2: str + ltoken_v2: str + ltmid_v2: str + ltuid_v2: str + + +class CNWebLoginResult(CookieLoginResult): + """Web login cookies. + + Returned by `client.cn_login_with_password`. + """ + + cookie_token_v2: str + account_mid_v2: str + account_id_v2: str + ltoken_v2: str + ltmid_v2: str + ltuid_v2: str + + +class MobileLoginResult(CookieLoginResult): + """Mobile number login cookies. + + Returned by `client.login_with_mobile_number`. + """ + + cookie_token_v2: str + account_mid_v2: str + account_id_v2: str + ltoken_v2: str + ltmid_v2: str + + +class DeviceGrantResult(pydantic.BaseModel): + """Cookies returned by the device grant endpoint.""" + + game_token: str + login_ticket: typing.Optional[str] = None + + @pydantic.root_validator(pre=True) + def _str_to_none(cls, data: typing.Dict[str, typing.Union[str, None]]) -> typing.Dict[str, typing.Union[str, None]]: + """Convert empty strings to `None`.""" + for key in data: + if data[key] == "" or data[key] == "None": + data[key] = None + return data + + +class GameLoginResult(pydantic.BaseModel): + """Game login result.""" + + combo_id: str + open_id: str + combo_token: str + heartbeat: bool + account_type: int diff --git a/genshin/models/auth/geetest.py b/genshin/models/auth/geetest.py new file mode 100644 index 00000000..bfa7464c --- /dev/null +++ b/genshin/models/auth/geetest.py @@ -0,0 +1,173 @@ +"""Geetest-related models""" + +import enum +import json +import typing + +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +from genshin.utility import auth as auth_utility + +__all__ = [ + "MMT", + "BaseMMT", + "BaseMMTResult", + "BaseSessionMMTResult", + "MMTResult", + "MMTv4", + "MMTv4Result", + "RiskyCheckMMT", + "RiskyCheckMMTResult", + "SessionMMT", + "SessionMMTResult", + "SessionMMTv4", + "SessionMMTv4Result", +] + + +class BaseMMT(pydantic.BaseModel): + """Base Geetest verification data model.""" + + new_captcha: int + success: int + + @pydantic.root_validator(pre=True) + def __parse_data(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + """Parse the data if it was provided in a raw format.""" + if "data" in data: + # Assume the data is aigis header and parse it + session_id = data["session_id"] + data = data["data"] + if isinstance(data, str): + data = json.loads(data) + + data["session_id"] = session_id + + return data + + +class MMT(BaseMMT): + """Geetest verification data.""" + + challenge: str + gt: str + + +class SessionMMT(MMT): + """Session-based geetest verification data.""" + + session_id: str + + def get_mmt(self) -> MMT: + """Get the base MMT data.""" + return MMT(**self.dict(exclude={"session_id"})) + + +class MMTv4(BaseMMT): + """Geetest verification data (V4).""" + + captcha_id: str = pydantic.Field(alias="gt") + risk_type: str + + +class SessionMMTv4(MMTv4): + """Session-based geetest verification data (V4).""" + + session_id: str + + def get_mmt(self) -> MMTv4: + """Get the base MMTv4 data.""" + return MMTv4(**self.dict(exclude={"session_id"})) + + +class RiskyCheckMMT(MMT): + """MMT returned by the risky check endpoint.""" + + check_id: str + + +class BaseMMTResult(pydantic.BaseModel): + """Base Geetest verification result model.""" + + def get_data(self) -> typing.Dict[str, typing.Any]: + """Get the base MMT result data. + + This method acts as `dict` but excludes the `session_id` field. + """ + return self.dict(exclude={"session_id"}) + + +class BaseSessionMMTResult(BaseMMTResult): + """Base session-based Geetest verification result model.""" + + session_id: str + + def to_aigis_header(self) -> str: + """Convert the result to `x-rpc-aigis` header.""" + return auth_utility.get_aigis_header(self.session_id, self.get_data()) + + +class MMTResult(BaseMMTResult): + """Geetest verification result.""" + + geetest_challenge: str + geetest_validate: str + geetest_seccode: str + + +class SessionMMTResult(MMTResult, BaseSessionMMTResult): + """Session-based geetest verification result.""" + + +class MMTv4Result(BaseMMTResult): + """Geetest verification result (V4).""" + + captcha_id: str + lot_number: str + pass_token: str + gen_time: str + captcha_output: str + + +class SessionMMTv4Result(MMTv4Result, BaseSessionMMTResult): + """Session-based geetest verification result (V4).""" + + +class RiskyCheckMMTResult(MMTResult): + """Risky check MMT result.""" + + check_id: str + + def to_rpc_risky(self) -> str: + """Convert the MMT result to a RPC risky header.""" + return auth_utility.generate_risky_header(self.check_id, self.geetest_challenge, self.geetest_validate) + + +class RiskyCheckAction(str, enum.Enum): + """Risky check action returned by the API.""" + + ACTION_NONE = "ACTION_NONE" + """No action required.""" + ACTION_GEETEST = "ACTION_GEETEST" + """Geetest verification required.""" + + +class RiskyCheckResult(pydantic.BaseModel): + """Model for the risky check result.""" + + id: str + action: RiskyCheckAction + mmt: typing.Optional[MMT] = pydantic.Field(alias="geetest") + + def to_mmt(self) -> RiskyCheckMMT: + """Convert the check result to a `RiskyCheckMMT` object.""" + if self.mmt is None: + raise ValueError("The check result does not contain a MMT object.") + + return RiskyCheckMMT(**self.mmt.dict(), check_id=self.id) diff --git a/genshin/models/auth/qrcode.py b/genshin/models/auth/qrcode.py new file mode 100644 index 00000000..63d45d0e --- /dev/null +++ b/genshin/models/auth/qrcode.py @@ -0,0 +1,61 @@ +"""Miyoushe QR Code Models""" + +import enum +import json +import typing + +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +__all__ = ["QRCodeCheckResult", "QRCodeCreationResult", "QRCodePayload", "QRCodeRawData", "QRCodeStatus"] + + +class QRCodeStatus(enum.Enum): + """QR code check status.""" + + INIT = "Init" + SCANNED = "Scanned" + CONFIRMED = "Confirmed" + + +class QRCodeRawData(pydantic.BaseModel): + """QR code raw data.""" + + account_id: str = pydantic.Field(alias="uid") + """Miyoushe account id.""" + game_token: str = pydantic.Field(alias="token") + + +class QRCodePayload(pydantic.BaseModel): + """QR code check result payload.""" + + proto: str + ext: str + raw: typing.Optional[QRCodeRawData] = None + + @pydantic.validator("raw", pre=True) + def _convert_raw_data(cls, value: typing.Optional[str] = None) -> typing.Union[QRCodeRawData, None]: + if value: + return QRCodeRawData(**json.loads(value)) + return None + + +class QRCodeCheckResult(pydantic.BaseModel): + """QR code check result.""" + + status: QRCodeStatus = pydantic.Field(alias="stat") + payload: QRCodePayload + + +class QRCodeCreationResult(pydantic.BaseModel): + """QR code creation result.""" + + app_id: str + ticket: str + device_id: str + url: str diff --git a/genshin/models/auth/responses.py b/genshin/models/auth/responses.py new file mode 100644 index 00000000..dd347e0a --- /dev/null +++ b/genshin/models/auth/responses.py @@ -0,0 +1,53 @@ +"""Auth endpoints responses models.""" + +import typing + +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +__all__ = ["Account", "ShieldLoginResponse"] + + +class Account(pydantic.BaseModel): + """Account data returned by the shield login endpoint.""" + + uid: str + name: str + email: str + mobile: str + is_email_verify: str + realname: str + identity_card: str + token: str + safe_mobile: str + facebook_name: str + google_name: str + twitter_name: str + game_center_name: str + apple_name: str + sony_name: str + tap_name: str + country: str + reactivate_ticket: str + area_code: str + device_grant_ticket: str + steam_name: str + unmasked_email: str + unmasked_email_type: int + cx_name: typing.Optional[str] = None + + +class ShieldLoginResponse(pydantic.BaseModel): + """Response model for the shield login endpoint.""" + + account: Account + device_grant_required: bool + safe_moblie_required: bool + realperson_required: bool + reactivate_required: bool + realname_operation: str diff --git a/genshin/models/auth/verification.py b/genshin/models/auth/verification.py new file mode 100644 index 00000000..0f207410 --- /dev/null +++ b/genshin/models/auth/verification.py @@ -0,0 +1,46 @@ +"""Email verification -related models""" + +import json +import typing + +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +__all__ = [ + "ActionTicket", + "VerifyStrategy", +] + + +class VerifyStrategy(pydantic.BaseModel): + """Verification strategy.""" + + ticket: str + verify_type: str + + +class ActionTicket(pydantic.BaseModel): + """Action ticket. Can be used to verify email addresses.""" + + risk_ticket: str + verify_str: VerifyStrategy + + @pydantic.root_validator(pre=True) + def __parse_data(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + """Parse the data if it was provided in a raw format.""" + verify_str = data["verify_str"] + if isinstance(verify_str, str): + data["verify_str"] = json.loads(verify_str) + + return data + + def to_rpc_verify_header(self) -> str: + """Convert the action ticket to `x-rpc-verify` header.""" + ticket = self.dict() + ticket["verify_str"] = json.dumps(ticket["verify_str"]) + return json.dumps(ticket) diff --git a/genshin/models/genshin/character.py b/genshin/models/genshin/character.py index 065cc14b..4720d009 100644 --- a/genshin/models/genshin/character.py +++ b/genshin/models/genshin/character.py @@ -4,6 +4,8 @@ import re import typing +from genshin.utility import deprecation + if typing.TYPE_CHECKING: import pydantic.v1 as pydantic else: @@ -20,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) -ICON_BASE = "https://upload-os-bbs.mihoyo.com/game_record/genshin/" +ICON_BASE = "https://enka.network/ui/" def _parse_icon(icon: typing.Union[str, int]) -> str: @@ -86,7 +88,14 @@ def _get_db_char( constants.CHARACTER_NAMES[lang][char.id] = char return char - return constants.DBChar(id or 0, icon_name, name or icon_name, element or "Anemo", rarity or 5, guessed=True) + return constants.DBChar( + id or 0, + icon_name, + name or icon_name, + element or "Anemo", + rarity or 5, + guessed=True, + ) if name: for char in constants.CHARACTER_NAMES[lang].values(): @@ -115,7 +124,7 @@ def __autocomplete(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str id, name, icon, element, rarity = (values.get(x) for x in ("id", "name", "icon", "element", "rarity")) char = _get_db_char(id, name, icon, element, rarity, lang=values["lang"]) - icon = _create_icon(char.icon_name, "character_icon/UI_AvatarIcon_{}") + icon = _create_icon(char.icon_name, "UI_AvatarIcon_{}") values["id"] = char.id values["name"] = char.name @@ -143,16 +152,21 @@ def __autocomplete(cls, values: typing.Dict[str, typing.Any]) -> typing.Dict[str return values @property + @deprecation.deprecated("gacha_art") def image(self) -> str: - return _create_icon(self.icon, "character_image/UI_AvatarIcon_{}@2x") + return _create_icon(self.icon, "UI_Gacha_AvatarImg_{}") + + @property + def gacha_art(self) -> str: + return _create_icon(self.icon, "UI_Gacha_AvatarImg_{}") @property def side_icon(self) -> str: - return _create_icon(self.icon, "character_side_icon/UI_AvatarIcon_Side_{}") + return _create_icon(self.icon, "UI_AvatarIcon_Side_{}") @property def card_icon(self) -> str: - return _create_icon(self.icon, "character_card_icon/UI_AvatarIcon_{}_Card") + return _create_icon(self.icon, "UI_AvatarIcon_{}_Card") @property def traveler_name(self) -> str: diff --git a/genshin/models/genshin/chronicle/abyss.py b/genshin/models/genshin/chronicle/abyss.py index 829fcd7d..159c2f8f 100644 --- a/genshin/models/genshin/chronicle/abyss.py +++ b/genshin/models/genshin/chronicle/abyss.py @@ -47,13 +47,13 @@ class CharacterRanks(APIModel): most_played: typing.Sequence[AbyssRankCharacter] = Aliased("reveal_rank", default=[], mi18n="bbs/go_fight_count") most_kills: typing.Sequence[AbyssRankCharacter] = Aliased("defeat_rank", default=[], mi18n="bbs/max_rout_count") strongest_strike: typing.Sequence[AbyssRankCharacter] = Aliased("damage_rank", default=[], mi18n="bbs/powerful_attack") - most_damage_taken: typing.Sequence[AbyssRankCharacter] = Aliased("take_damage_rank", default=[], mi18n="bbs/receive_max_damage") - most_bursts_used: typing.Sequence[AbyssRankCharacter] = Aliased("energy_skill_rank", default=[], mi18n="bbs/element_break_count") - most_skills_used: typing.Sequence[AbyssRankCharacter] = Aliased("normal_skill_rank", default=[], mi18n="bbs/element_skill_use_count") + most_damage_taken: typing.Sequence[AbyssRankCharacter] = Aliased("take_damage_rank", default=[], mi18n="bbs/receive_max_damage") # noqa: E501 + most_bursts_used: typing.Sequence[AbyssRankCharacter] = Aliased("energy_skill_rank", default=[], mi18n="bbs/element_break_count") # noqa: E501 + most_skills_used: typing.Sequence[AbyssRankCharacter] = Aliased("normal_skill_rank", default=[], mi18n="bbs/element_skill_use_count") # noqa: E501 # fmt: on def as_dict(self, lang: typing.Optional[str] = None) -> typing.Mapping[str, typing.Any]: - """Helper function which turns fields into properly named ones""" + """Turn fields into properly named ones.""" return { self._get_mi18n(field, lang or self.lang): getattr(self, field.name) for field in self.__fields__.values() diff --git a/genshin/models/genshin/chronicle/notes.py b/genshin/models/genshin/chronicle/notes.py index 2ada57e7..439bd301 100644 --- a/genshin/models/genshin/chronicle/notes.py +++ b/genshin/models/genshin/chronicle/notes.py @@ -15,6 +15,9 @@ from genshin.models.model import Aliased, APIModel __all__ = [ + "ArchonQuest", + "ArchonQuestProgress", + "ArchonQuestStatus", "AttendanceReward", "AttendanceRewardStatus", "DailyTasks", @@ -126,6 +129,31 @@ class DailyTasks(APIModel): attendance_visible: bool +class ArchonQuestStatus(str, enum.Enum): + """Archon quest status.""" + + ONGOING = "StatusOngoing" + NOT_OPEN = "StatusNotOpen" + + +class ArchonQuest(APIModel): + """Archon Quest.""" + + id: int + status: ArchonQuestStatus + chapter_num: str + chapter_title: str + + +class ArchonQuestProgress(APIModel): + """Archon Quest Progress.""" + + list: typing.Sequence[ArchonQuest] + mainlines_finished: bool = Aliased("is_finish_all_mainline") + archon_quest_unlocked: bool = Aliased("is_open_archon_quest") + interchapters_finished: bool = Aliased("is_finish_all_interchapter") + + class Notes(APIModel): """Real-Time notes.""" @@ -149,6 +177,8 @@ class Notes(APIModel): expeditions: typing.Sequence[Expedition] max_expeditions: int = Aliased("max_expedition_num") + archon_quest_progress: ArchonQuestProgress + @property def resin_recovery_time(self) -> datetime.datetime: """The time when resin will be recovered.""" diff --git a/genshin/models/genshin/chronicle/stats.py b/genshin/models/genshin/chronicle/stats.py index fb4d9e75..0734a886 100644 --- a/genshin/models/genshin/chronicle/stats.py +++ b/genshin/models/genshin/chronicle/stats.py @@ -53,7 +53,7 @@ class Stats(APIModel): # fmt: on def as_dict(self, lang: typing.Optional[str] = None) -> typing.Mapping[str, typing.Any]: - """Helper function which turns fields into properly named ones""" + """Turn fields into properly named ones.""" return { self._get_mi18n(field, lang or self.lang): getattr(self, field.name) for field in self.__fields__.values() diff --git a/genshin/models/genshin/daily.py b/genshin/models/genshin/daily.py index 1b2e2443..113eaee6 100644 --- a/genshin/models/genshin/daily.py +++ b/genshin/models/genshin/daily.py @@ -1,6 +1,5 @@ """Daily reward models.""" -import calendar import datetime import typing @@ -19,8 +18,7 @@ class DailyRewardInfo(typing.NamedTuple): def missed_rewards(self) -> int: cn_timezone = datetime.timezone(datetime.timedelta(hours=8)) now = datetime.datetime.now(cn_timezone) - month_days = calendar.monthrange(now.year, now.month)[1] - return month_days - self.claimed_rewards + return now.day - self.claimed_rewards class DailyReward(APIModel): diff --git a/genshin/models/genshin/lineup.py b/genshin/models/genshin/lineup.py index 6ae71946..76f3865e 100644 --- a/genshin/models/genshin/lineup.py +++ b/genshin/models/genshin/lineup.py @@ -296,7 +296,7 @@ def __parse_characters(cls, value: typing.Any) -> typing.Any: if isinstance(value[0], typing.Sequence): return value - return [[character for character in group["group"]] for group in value] + return [list(group["group"]) for group in value] class Lineup(LineupPreview): diff --git a/genshin/models/genshin/teapot.py b/genshin/models/genshin/teapot.py index c12e3c90..9fb7eefc 100644 --- a/genshin/models/genshin/teapot.py +++ b/genshin/models/genshin/teapot.py @@ -61,7 +61,7 @@ class TeapotReplica(APIModel, Unique): images: typing.List[str] = Aliased("imgs") created_at: datetime.datetime stats: TeapotReplicaStats - lang: str + lang: str # type: ignore author: TeapotReplicaAuthor diff --git a/genshin/models/honkai/battlesuit.py b/genshin/models/honkai/battlesuit.py index e77fbe34..b7ba35eb 100644 --- a/genshin/models/honkai/battlesuit.py +++ b/genshin/models/honkai/battlesuit.py @@ -26,6 +26,7 @@ "YiNeng": "PSY", "LiangZi": "QUA", "XuShu": "IMG", + "Xingchen": "SD", } ICON_BASE = "https://upload-os-bbs.mihoyo.com/game_record/honkai3rd/global/SpriteOutput/" diff --git a/genshin/models/honkai/chronicle/modes.py b/genshin/models/honkai/chronicle/modes.py index 92067ebd..50a85116 100644 --- a/genshin/models/honkai/chronicle/modes.py +++ b/genshin/models/honkai/chronicle/modes.py @@ -18,8 +18,8 @@ from genshin.models.model import Aliased, APIModel, Unique __all__ = [ - "Boss", "ELF", + "Boss", "ElysianRealm", "MemorialArena", "MemorialBattle", diff --git a/genshin/models/honkai/chronicle/stats.py b/genshin/models/honkai/chronicle/stats.py index b7f57490..1bb6745d 100644 --- a/genshin/models/honkai/chronicle/stats.py +++ b/genshin/models/honkai/chronicle/stats.py @@ -24,7 +24,7 @@ def _model_to_dict(model: APIModel, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: - """Helper function which turns fields into properly named ones""" + """Turn fields into properly named ones.""" ret: typing.Dict[str, typing.Any] = {} for field in model.__fields__.values(): if not field.field_info.extra.get("mi18n"): @@ -123,7 +123,7 @@ class OldAbyssStats(APIModel): raw_tier: int = Aliased("latest_area", mi18n="bbs/settled_level") raw_latest_rank: typing.Optional[int] = Aliased("latest_level", mi18n="bbs/rank") # TODO: Add proper key - latest_type: str = Aliased( mi18n="bbs/latest_type") + latest_type: str = Aliased( mi18n="bbs/latest_type") # fmt: on @pydantic.validator("raw_q_singularis_rank", "raw_dirac_sea_rank", "raw_latest_rank", pre=True) @@ -240,6 +240,7 @@ def __pack_gamemode_stats(cls, values: typing.Dict[str, typing.Any]) -> typing.D return values def as_dict(self, lang: str = "en-us") -> typing.Mapping[str, typing.Any]: + """Turn fields into properly named ones.""" return _model_to_dict(self, lang) diff --git a/genshin/models/hoyolab/announcements.py b/genshin/models/hoyolab/announcements.py index fb667ec5..920a3080 100644 --- a/genshin/models/hoyolab/announcements.py +++ b/genshin/models/hoyolab/announcements.py @@ -31,5 +31,5 @@ class Announcement(APIModel, Unique): tag_start_time: datetime.datetime tag_end_time: datetime.datetime - lang: str + lang: str # type: ignore has_content: bool diff --git a/genshin/models/starrail/chronicle/challenge.py b/genshin/models/starrail/chronicle/challenge.py index 1441f190..cf6a529e 100644 --- a/genshin/models/starrail/chronicle/challenge.py +++ b/genshin/models/starrail/chronicle/challenge.py @@ -1,6 +1,14 @@ """Starrail chronicle challenge.""" -from typing import List, Optional +from typing import TYPE_CHECKING, List, Optional + +if TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic from genshin.models.model import Aliased, APIModel from genshin.models.starrail.character import FloorCharacter diff --git a/genshin/models/starrail/chronicle/characters.py b/genshin/models/starrail/chronicle/characters.py index f6d3bde3..21d886e4 100644 --- a/genshin/models/starrail/chronicle/characters.py +++ b/genshin/models/starrail/chronicle/characters.py @@ -1,20 +1,50 @@ """Starrail chronicle character.""" -from typing import List, Optional +import enum +import typing +from typing import Any, Mapping, Optional, Sequence -from genshin.models.model import APIModel +if typing.TYPE_CHECKING: + import pydantic.v1 as pydantic +else: + try: + import pydantic.v1 as pydantic + except ImportError: + import pydantic + +from genshin.models.model import Aliased, APIModel from .. import character __all__ = [ + "CharacterProperty", + "ModifyRelicProperty", + "PropertyInfo", "Rank", + "RecommendProperty", "Relic", + "RelicProperty", + "Skill", + "SkillStage", "StarRailDetailCharacter", "StarRailDetailCharacters", "StarRailEquipment", + "StarRailPath", ] +class StarRailPath(enum.IntEnum): + """StarRail character path.""" + + DESTRUCTION = 1 + THE_HUNT = 2 + ERUDITION = 3 + HARMONY = 4 + NIHILITY = 5 + PRESERVATION = 6 + ABUNDANCE = 7 + + class StarRailEquipment(APIModel): """Character equipment.""" @@ -24,6 +54,29 @@ class StarRailEquipment(APIModel): name: str desc: str icon: str + rarity: int + wiki: str + + +class PropertyInfo(APIModel): + """Relic property info.""" + + property_type: int + name: str + icon: str + property_name_relic: str + property_name_filter: str + + +class RelicProperty(APIModel): + """Relic property.""" + + property_type: int + value: str + times: int + preferred: bool + recommended: bool + info: PropertyInfo class Relic(APIModel): @@ -36,6 +89,9 @@ class Relic(APIModel): desc: str icon: str rarity: int + wiki: str + main_property: RelicProperty + properties: Sequence[RelicProperty] class Rank(APIModel): @@ -49,17 +105,119 @@ class Rank(APIModel): is_unlocked: bool +class CharacterProperty(APIModel): + """Base character property.""" + + property_type: int + base: str + add: str + final: str + preferred: bool + recommended: bool + info: PropertyInfo + + +class SkillStage(APIModel): + """Character skill stage.""" + + name: str + desc: str + level: int + remake: str + item_url: str + is_activated: bool + is_rank_work: bool + + +class Skill(APIModel): + """Character skill.""" + + point_id: str + point_type: int + item_url: str + level: int + is_activated: bool + is_rank_work: bool + pre_point: str + anchor: str + remake: str + skill_stages: Sequence[SkillStage] + + +class RecommendProperty(APIModel): + """Character recommended and preferred properties.""" + + recommend_relic_properties: Sequence[int] + custom_relic_properties: Sequence[int] + is_custom_property_valid: bool + + class StarRailDetailCharacter(character.StarRailPartialCharacter): """StarRail character with equipment and relics.""" image: str equip: Optional[StarRailEquipment] - relics: List[Relic] - ornaments: List[Relic] - ranks: List[Rank] + relics: Sequence[Relic] + ornaments: Sequence[Relic] + ranks: Sequence[Rank] + properties: Sequence[CharacterProperty] + path: StarRailPath = Aliased("base_type") + figure_path: str + skills: Sequence[Skill] + + +class ModifyRelicProperty(APIModel): + """Modify relic property.""" + + property_type: int + modify_property_type: int class StarRailDetailCharacters(APIModel): """StarRail characters.""" - avatar_list: List[StarRailDetailCharacter] + avatar_list: Sequence[StarRailDetailCharacter] + equip_wiki: Mapping[str, str] + relic_wiki: Mapping[str, str] + property_info: Mapping[str, PropertyInfo] + recommend_property: Mapping[str, RecommendProperty] + relic_properties: Sequence[ModifyRelicProperty] + + @pydantic.root_validator(pre=True) + def __fill_additional_fields(cls, values: Mapping[str, Any]) -> Mapping[str, Any]: + """Fill additional fields for convenience.""" + characters = values.get("avatar_list", []) + props_info = values.get("property_info", {}) + rec_props = values.get("recommend_property", {}) + equip_wiki = values.get("equip_wiki", {}) + relic_wiki = values.get("relic_wiki", {}) + + for char in characters: + char_id = str(char["id"]) + char_rec_props = rec_props[char_id]["recommend_relic_properties"] + char_custom_props = rec_props[char_id]["custom_relic_properties"] + + for relic in char["relics"] + char["ornaments"]: + prop_type = relic["main_property"]["property_type"] + relic["main_property"]["info"] = props_info[str(prop_type)] + relic["main_property"]["recommended"] = prop_type in char_rec_props + relic["main_property"]["preferred"] = prop_type in char_custom_props + + for prop in relic["properties"]: + prop_type = prop["property_type"] + prop["recommended"] = prop_type in char_rec_props + prop["preferred"] = prop_type in char_custom_props + prop["info"] = props_info[str(prop_type)] + + relic["wiki"] = relic_wiki.get(str(relic["id"]), "") + + for prop in char["properties"]: + prop_type = prop["property_type"] + prop["recommended"] = prop_type in char_rec_props + prop["preferred"] = prop_type in char_custom_props + prop["info"] = props_info[str(prop_type)] + + if char["equip"]: + char["equip"]["wiki"] = equip_wiki.get(str(char["equip"]["id"]), "") + + return values diff --git a/genshin/models/starrail/chronicle/notes.py b/genshin/models/starrail/chronicle/notes.py index d3fa8f8e..ca09e147 100644 --- a/genshin/models/starrail/chronicle/notes.py +++ b/genshin/models/starrail/chronicle/notes.py @@ -15,6 +15,7 @@ class StarRailExpedition(APIModel): status: typing.Literal["Ongoing", "Finished"] remaining_time: datetime.timedelta name: str + item_url: str @property def finished(self) -> bool: diff --git a/genshin/paginators/base.py b/genshin/paginators/base.py index cf5be77e..a466c59a 100644 --- a/genshin/paginators/base.py +++ b/genshin/paginators/base.py @@ -16,7 +16,7 @@ async def flatten(iterable: typing.AsyncIterable[T]) -> typing.Sequence[T]: """Flatten an async iterable.""" if isinstance(iterable, Paginator): - return await iterable.flatten() + return await iterable.flatten() # type: ignore return [x async for x in iterable] diff --git a/genshin/types.py b/genshin/types.py index 38023cc9..982f78f4 100644 --- a/genshin/types.py +++ b/genshin/types.py @@ -39,6 +39,9 @@ class Game(str, enum.Enum): THEMIS_TW = "tot_tw" """Tears of Themis (Taiwan)""" + ZZZ = "nap" + """Zenless Zone Zero""" + IDOr = typing.Union[int, UniqueT] """Allows partial objects.""" diff --git a/genshin/utility/__init__.py b/genshin/utility/__init__.py index cd1e07c3..fd4b76bc 100644 --- a/genshin/utility/__init__.py +++ b/genshin/utility/__init__.py @@ -1,6 +1,6 @@ """Utilities for genshin.py.""" -from . import geetest +from .auth import * from .concurrency import * from .ds import * from .extdb import * diff --git a/genshin/utility/auth.py b/genshin/utility/auth.py new file mode 100644 index 00000000..618a8292 --- /dev/null +++ b/genshin/utility/auth.py @@ -0,0 +1,170 @@ +"""Auth utilities.""" + +import base64 +import hmac +import json +import typing +from hashlib import sha256 + +from genshin import constants, types + +__all__ = ["encrypt_credentials", "generate_sign"] + + +# RSA key used for OS app/web login +LOGIN_KEY_TYPE_1 = b""" +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4PMS2JVMwBsOIrYWRluY +wEiFZL7Aphtm9z5Eu/anzJ09nB00uhW+ScrDWFECPwpQto/GlOJYCUwVM/raQpAj +/xvcjK5tNVzzK94mhk+j9RiQ+aWHaTXmOgurhxSp3YbwlRDvOgcq5yPiTz0+kSeK +ZJcGeJ95bvJ+hJ/UMP0Zx2qB5PElZmiKvfiNqVUk8A8oxLJdBB5eCpqWV6CUqDKQ +KSQP4sM0mZvQ1Sr4UcACVcYgYnCbTZMWhJTWkrNXqI8TMomekgny3y+d6NX/cFa6 +6jozFIF4HCX5aW8bp8C8vq2tFvFbleQ/Q3CU56EWWKMrOcpmFtRmC18s9biZBVR/ +8QIDAQAB +-----END PUBLIC KEY----- +""" + +# RSA key used for CN app/game and game login +LOGIN_KEY_TYPE_2 = b""" +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDvekdPMHN3AYhm/vktJT+YJr7 +cI5DcsNKqdsx5DZX0gDuWFuIjzdwButrIYPNmRJ1G8ybDIF7oDW2eEpm5sMbL9zs +9ExXCdvqrn51qELbqj0XxtMTIpaCHFSI50PfPpTFV9Xt/hmyVwokoOXFlAEgCn+Q +CgGs52bFoYMtyi+xEQIDAQAB +-----END PUBLIC KEY----- +""" + +WEB_LOGIN_HEADERS = { + "x-rpc-app_id": "c9oqaq3s3gu8", + "x-rpc-client_type": "4", + # If not equal account.hoyolab.com It's will return retcode 1200 [Unauthorized] + "Origin": "https://account.hoyolab.com", + "Referer": "https://account.hoyolab.com/", +} + +APP_LOGIN_HEADERS = { + "x-rpc-app_id": "c9oqaq3s3gu8", + "x-rpc-client_type": "2", + # Passing "x-rpc-device_id" header will trigger email verification + # (unless the device_id is already verified). + # + # For some reason, without this header, email verification is not triggered. + # "x-rpc-device_id": "1c33337bd45c1bfs", +} + +CN_LOGIN_HEADERS = { + "x-rpc-app_id": "bll8iq97cem8", + "x-rpc-client_type": "4", + "x-rpc-source": "v2.webLogin", + "x-rpc-device_id": "586f2440-856a-4243-8076-2b0a12314197", +} + +EMAIL_SEND_HEADERS = { + "x-rpc-app_id": "c9oqaq3s3gu8", + "x-rpc-client_type": "2", +} + +EMAIL_VERIFY_HEADERS = { + "x-rpc-app_id": "c9oqaq3s3gu8", + "x-rpc-client_type": "2", +} + +CREATE_MMT_HEADERS = { + types.Region.OVERSEAS: { + "x-rpc-challenge_path": "https://bbs-api-os.hoyolab.com/game_record/app/hkrpg/api/challenge", + "x-rpc-app_version": "2.55.0", + "x-rpc-challenge_game": "6", + "x-rpc-client_type": "5", + }, + types.Region.CHINESE: { + "x-rpc-app_version": "2.60.1", + "x-rpc-client_type": "5", + "x-rpc-challenge_game": "6", + "x-rpc-page": "v1.4.1-rpg_#/rpg", + "x-rpc-tool-version": "v1.4.1-rpg", + }, +} + +DEVICE_ID = "D6AF5103-D297-4A01-B86A-87F87DS5723E" + +RISKY_CHECK_HEADERS = { + "x-rpc-client_type": "1", + "x-rpc-channel_id": "1", + "x-rpc-game_biz": "hkrpg_global", +} + +SHIELD_LOGIN_HEADERS = { + "x-rpc-client_type": "1", + "x-rpc-channel_id": "1", + "x-rpc-game_biz": "hkrpg_global", + "x-rpc-device_id": DEVICE_ID, +} + +GRANT_TICKET_HEADERS = { + "x-rpc-client_type": "1", + "x-rpc-channel_id": "1", + "x-rpc-game_biz": "hkrpg_global", + "x-rpc-device_id": DEVICE_ID, + "x-rpc-language": "ru", +} + +GAME_LOGIN_HEADERS = { + "x-rpc-client_type": "1", + "x-rpc-channel_id": "1", + "x-rpc-game_biz": "hkrpg_global", + "x-rpc-device_id": DEVICE_ID, +} + +GEETEST_LANGS = { + "简体中文": "zh-cn", + "繁體中文": "zh-tw", + "Deutsch": "de", + "English": "en", + "Español": "es", + "Français": "fr", + "Indonesia": "id", + "Italiano": "it", + "日本語": "ja", + "한국어": "ko", + "Português": "pt-pt", + "Pусский": "ru", + "ภาษาไทย": "th", + "Tiếng Việt": "vi", + "Türkçe": "tr", +} + + +def lang_to_geetest_lang(lang: str) -> str: + """Convert `client.lang` to geetest lang.""" + return GEETEST_LANGS.get(constants.LANGS.get(lang, "en-us"), "en") + + +def encrypt_credentials(text: str, key_type: typing.Literal[1, 2]) -> str: + """Encrypt text for geetest.""" + import rsa + + public_key = rsa.PublicKey.load_pkcs1_openssl_pem(LOGIN_KEY_TYPE_1 if key_type == 1 else LOGIN_KEY_TYPE_2) + crypto = rsa.encrypt(text.encode("utf-8"), public_key) + return base64.b64encode(crypto).decode("utf-8") + + +def get_aigis_header(session_id: str, mmt_data: typing.Dict[str, typing.Any]) -> str: + """Get aigis header.""" + return f"{session_id};{base64.b64encode(json.dumps(mmt_data).encode()).decode()}" + + +def generate_sign(data: typing.Dict[str, typing.Any], key: str) -> str: + """Generate a sign for the given `data` and `app_key`.""" + string = "" + for k in sorted(data.keys()): + string += k + "=" + str(data[k]) + "&" + return hmac.new(key.encode(), string[:-1].encode(), sha256).hexdigest() + + +def generate_risky_header( + check_id: str, + challenge: str = "", + validate: str = "", +) -> str: + """Generate risky header for geetest verification.""" + return f"id={check_id};c={challenge};s={validate}|jordan;v={validate}" diff --git a/genshin/utility/ds.py b/genshin/utility/ds.py index 1cd169f5..2a9e3164 100644 --- a/genshin/utility/ds.py +++ b/genshin/utility/ds.py @@ -9,7 +9,13 @@ from genshin import constants, types -__all__ = ["generate_cn_dynamic_secret", "generate_dynamic_secret", "get_ds_headers"] +__all__ = [ + "generate_cn_dynamic_secret", + "generate_dynamic_secret", + "generate_geetest_ds", + "generate_passport_ds", + "get_ds_headers", +] def generate_dynamic_secret(salt: str = constants.DS_SALT[types.Region.OVERSEAS]) -> str: @@ -59,3 +65,22 @@ def get_ds_headers( else: raise TypeError(f"{region!r} is not a valid region.") return ds_headers + + +def generate_passport_ds(body: typing.Mapping[str, typing.Any]) -> str: + """Create a dynamic secret for Miyoushe passport API.""" + salt = constants.DS_SALT["cn_passport"] + t = int(time.time()) + r = "".join(random.sample(string.ascii_letters, 6)) + b = json.dumps(body) + h = hashlib.md5(f"salt={salt}&t={t}&r={r}&b={b}&q=".encode()).hexdigest() + result = f"{t},{r},{h}" + return result + + +def generate_geetest_ds(region: types.Region) -> str: + """Create a dynamic secret for geetest API endpoint.""" + t = int(time.time()) + r = random.randint(100000, 200000) + h = hashlib.md5(f"salt={constants.DS_SALT[region]}&t={t}&r={r}&b=&q=is_high=false".encode()).hexdigest() + return f"{t},{r},{h}" diff --git a/genshin/utility/extdb.py b/genshin/utility/extdb.py index 52fb5ac6..b3058cd3 100644 --- a/genshin/utility/extdb.py +++ b/genshin/utility/extdb.py @@ -2,6 +2,7 @@ import asyncio import json +import logging import time import typing import warnings @@ -20,6 +21,8 @@ "update_characters_genshindata", ) +LOGGER_ = logging.getLogger(__name__) + CACHE_FILE = fs.get_tempdir() / "characters.json" if CACHE_FILE.exists() and time.time() - CACHE_FILE.stat().st_mtime < 7 * 24 * 60 * 60: @@ -170,8 +173,10 @@ async def update_characters_enka(langs: typing.Sequence[str] = ()) -> None: continue # traveler element for short_lang, loc in locs.items(): + if (lang := ENKA_LANG_MAP.get(short_lang)) is None: + continue update_character_name( - lang=ENKA_LANG_MAP[short_lang], + lang=lang, id=int(strid), icon_name=char["SideIconName"][len("UI_AvatarIcon_Side_") :], # noqa: E203 name=loc[str(char["NameTextMapHash"])], @@ -235,7 +240,7 @@ async def update_characters_any( try: await updator(langs) except Exception: - continue + LOGGER_.exception("Failed to update characters with %s", updator.__name__) else: return diff --git a/genshin/utility/geetest.py b/genshin/utility/geetest.py deleted file mode 100644 index 300ba2ae..00000000 --- a/genshin/utility/geetest.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Geetest utilities.""" - -import base64 -import json -import typing - -__all__ = ["encrypt_geetest_credentials"] - - -# RSA key is the same for app and web login -LOGIN_KEY_CERT = b""" ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4PMS2JVMwBsOIrYWRluY -wEiFZL7Aphtm9z5Eu/anzJ09nB00uhW+ScrDWFECPwpQto/GlOJYCUwVM/raQpAj -/xvcjK5tNVzzK94mhk+j9RiQ+aWHaTXmOgurhxSp3YbwlRDvOgcq5yPiTz0+kSeK -ZJcGeJ95bvJ+hJ/UMP0Zx2qB5PElZmiKvfiNqVUk8A8oxLJdBB5eCpqWV6CUqDKQ -KSQP4sM0mZvQ1Sr4UcACVcYgYnCbTZMWhJTWkrNXqI8TMomekgny3y+d6NX/cFa6 -6jozFIF4HCX5aW8bp8C8vq2tFvFbleQ/Q3CU56EWWKMrOcpmFtRmC18s9biZBVR/ -8QIDAQAB ------END PUBLIC KEY----- -""" - -WEB_LOGIN_HEADERS = { - "x-rpc-app_id": "c9oqaq3s3gu8", - "x-rpc-client_type": "4", - "x-rpc-sdk_version": "2.14.1", - "x-rpc-game_biz": "bbs_oversea", - "x-rpc-source": "v2.webLogin", - "x-rpc-referrer": "https://www.hoyolab.com", - # If not equal account.hoyolab.com It's will return retcode 1200 [Unauthorized] - "Origin": "https://account.hoyolab.com", - "Referer": "https://account.hoyolab.com/", -} - -APP_LOGIN_HEADERS = { - "x-rpc-app_id": "c9oqaq3s3gu8", - "x-rpc-app_version": "2.47.0", - "x-rpc-client_type": "2", - "x-rpc-sdk_version": "2.22.0", - "x-rpc-game_biz": "bbs_oversea", - "Origin": "https://account.hoyoverse.com", - "Referer": "https://account.hoyoverse.com/", -} - -EMAIL_SEND_HEADERS = { - "x-rpc-app_id": "c9oqaq3s3gu8", -} - -EMAIL_VERIFY_HEADERS = { - "x-rpc-app_id": "c9oqaq3s3gu8", -} - - -def encrypt_geetest_credentials(text: str) -> str: - """Encrypt text for geetest.""" - import rsa - - public_key = rsa.PublicKey.load_pkcs1_openssl_pem(LOGIN_KEY_CERT) - crypto = rsa.encrypt(text.encode("utf-8"), public_key) - return base64.b64encode(crypto).decode("utf-8") - - -def get_aigis_header(session_id: str, mmt_data: typing.Dict[str, typing.Any]) -> str: - """Get aigis header.""" - return f"{session_id};{base64.b64encode(json.dumps(mmt_data).encode()).decode()}" diff --git a/noxfile.py b/noxfile.py index 550a0401..93c6c5a4 100644 --- a/noxfile.py +++ b/noxfile.py @@ -31,7 +31,7 @@ def install_requirements(session: nox.Session, *requirements: str, literal: bool """Install requirements.""" if not literal and all(requirement.isalpha() for requirement in requirements): files = ["requirements.txt"] + [f"./genshin-dev/{requirement}-requirements.txt" for requirement in requirements] - requirements = ("pip",) + tuple(arg for file in files for arg in ("-r", file)) + requirements = ("pip", *tuple(arg for file in files for arg in ("-r", file))) session.install("--upgrade", *requirements, silent=not isverbose()) @@ -52,21 +52,29 @@ def docs(session: nox.Session) -> None: def lint(session: nox.Session) -> None: """Run this project's modules against the pre-defined flake8 linters.""" install_requirements(session, "lint") - session.run("flake8", "--version") - session.run("flake8", *GENERAL_TARGETS, *verbose_args()) + session.run("ruff", "check", *GENERAL_TARGETS, *verbose_args()) @nox.session() def reformat(session: nox.Session) -> None: """Reformat this project's modules to fit the standard style.""" install_requirements(session, "reformat") - session.run("black", *GENERAL_TARGETS, *verbose_args()) - session.run("isort", *GENERAL_TARGETS, *verbose_args()) - - session.log("sort-all") - LOGGER.disabled = True - session.run("sort-all", *map(str, pathlib.Path(PACKAGE).glob("**/*.py")), success_codes=[0, 1]) - LOGGER.disabled = False + session.run("python", "-m", "black", *GENERAL_TARGETS, *verbose_args()) + # sort __all__ and format imports + session.run( + "python", + "-m", + "ruff", + "check", + "--preview", + "--select", + "RUF022,I", + "--fix", + *GENERAL_TARGETS, + *verbose_args(), + ) + # fix all fixable linting errors + session.run("ruff", "check", "--fix", *GENERAL_TARGETS, *verbose_args()) @nox.session(name="test") diff --git a/pyproject.toml b/pyproject.toml index 804aafe2..96634f51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,72 @@ build-backend = "setuptools.build_meta" [tool.black] line-length = 120 -target-version = ["py39"] -[tool.isort] -profile = "black" +[tool.ruff] +line-length = 120 +target-version = "py38" + +[tool.ruff.lint] +select = [ + "A", + "C4", + "C9", + "D", + "E", + "F", + "S", + "W", + "T20", + "PT", + "RSE" +] +exclude = ["tests", "test.py"] + +# A001, A002, A003: `id` variable/parameter/attribute +# C408: dict() with keyword arguments +# D101: Missing docstring in public module +# D105: Missing docstring in magic method +# D106: Missing docstring Model.Config +# D400: First line should end with a period +# D419: Docstring is empty +# PT007: Wrong values type in `@pytest.mark.parametrize` expected `list` of `tuple` +# PT018: Assertion should be broken down into multiple parts +# S101: Use of assert for type checking +# S303: Use of md5 +# S311: Use of pseudo-random generators +# S324: Use of md5 without usedforsecurity=False (3.9+) +ignore = [ + "A001", "A002", "A003", + "C408", + "D100", "D105", "D106", "D400", "D419", + "PT007", "PT018", + "S101", "S303", "S311", "S324", +] + +# auto-fixing too intrusive +# F401: Unused import +# F841: Unused variable +# B007: Unused loop variable +unfixable = ["F401", "F841", "B007"] + +[tool.ruff.lint.per-file-ignores] +# F401: unused import. +# F403: cannot detect unused vars if we use starred import +# D10*: docstrings +# S10*: hardcoded passwords +# F841: unused variable +"**/__init__.py" = ["F401", "F403"] +"tests/**" = ["D10", "S10", "F841"] + +[tool.ruff.lint.mccabe] +max-complexity = 16 + +[tool.ruff.lint.pycodestyle] +max-line-length = 130 + +[tool.ruff.lint.pydocstyle] +convention = "numpy" +ignore-decorators = ["property"] [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/requirements.txt b/requirements.txt index 95cbe366..fa1c601e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ browser-cookie3 rsa aioredis click +qrcode[pil] \ No newline at end of file diff --git a/setup.py b/setup.py index 96e87d66..bb51b5b6 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,10 @@ """Run setuptools.""" + from setuptools import find_packages, setup setup( name="genshin", - version="1.6.2", + version="1.7.1", author="thesadru", author_email="thesadru@gmail.com", description="An API wrapper for Genshin Impact.", @@ -17,9 +18,9 @@ python_requires=">=3.8", install_requires=["aiohttp", "pydantic"], extras_require={ - "all": ["browser-cookie3", "rsa", "click"], + "all": ["browser-cookie3", "rsa", "click", "qrcode[pil]"], "cookies": ["browser-cookie3"], - "geetest": ["rsa"], + "auth": ["rsa", "qrcode[pil]"], "cli": ["click"], }, include_package_data=True, diff --git a/tests/client/components/test_calculator.py b/tests/client/components/test_calculator.py index d48ea499..1334166f 100644 --- a/tests/client/components/test_calculator.py +++ b/tests/client/components/test_calculator.py @@ -7,7 +7,7 @@ async def test_calculator_characters(client: genshin.Client): character = min(characters, key=lambda character: character.id) assert character.name == "Kamisato Ayaka" - assert "genshin" in character.icon + assert "enka.network" in character.icon assert character.max_level == 90 assert character.level == 0 assert not character.collab diff --git a/tests/client/components/test_daily.py b/tests/client/components/test_daily.py index 95dfe5cb..9160a4ab 100644 --- a/tests/client/components/test_daily.py +++ b/tests/client/components/test_daily.py @@ -13,7 +13,7 @@ async def test_daily_reward(lclient: genshin.Client): try: reward = await lclient.claim_daily_reward() - except genshin.GeetestTriggered: + except genshin.DailyGeetestTriggered: pytest.skip("Geetest triggered on daily reward.") except genshin.AlreadyClaimed: assert signed_in @@ -35,7 +35,7 @@ async def test_starrail_daily_reward(lclient: genshin.Client): try: reward = await lclient.claim_daily_reward(game=genshin.types.Game.STARRAIL) - except genshin.GeetestTriggered: + except genshin.DailyGeetestTriggered: pytest.skip("Geetest triggered on daily reward.") except genshin.AlreadyClaimed: assert signed_in diff --git a/tests/client/components/test_genshin_chronicle.py b/tests/client/components/test_genshin_chronicle.py index 41ed193e..ce4daff3 100644 --- a/tests/client/components/test_genshin_chronicle.py +++ b/tests/client/components/test_genshin_chronicle.py @@ -59,6 +59,3 @@ async def test_full_genshin_user(client: genshin.Client, genshin_uid: int): async def test_exceptions(client: genshin.Client): with pytest.raises(genshin.DataNotPublic): await client.get_record_cards(10000000) - - with pytest.raises(genshin.AccountNotFound): - await client.get_spiral_abyss(70000001) diff --git a/tests/models/test_model.py b/tests/models/test_model.py index c9978389..57c5891c 100644 --- a/tests/models/test_model.py +++ b/tests/models/test_model.py @@ -24,14 +24,14 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... "name": "Kamisato Ayaka", "element": "Cryo", "rarity": 5, - "icon": "https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_Ayaka.png", + "icon": "https://enka.network/ui/UI_AvatarIcon_Ayaka.png", }, LiteralCharacter( id=10000002, name="Kamisato Ayaka", element="Cryo", rarity=5, - icon="https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_Ayaka.png", + icon="https://enka.network/ui/UI_AvatarIcon_Ayaka.png", lang="en-us", ), ), @@ -46,7 +46,7 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... name="Jean", element="Anemo", rarity=5, - icon="https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_Qin.png", + icon="https://enka.network/ui/UI_AvatarIcon_Qin.png", lang="en-us", ), ), @@ -62,7 +62,7 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... name="Signora", element="Anemo", # Anemo is the arbitrary fallback rarity=6, # 5 is the arbitrary fallback - icon="https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_Signora.png", + icon="https://enka.network/ui/UI_AvatarIcon_Signora.png", lang="en-us", ), ), @@ -79,7 +79,7 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... name="Traveler", element="Light", rarity=5, - icon="https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_PlayerBoy.png", + icon="https://enka.network/ui/UI_AvatarIcon_PlayerBoy.png", lang="en-us", ), ), @@ -97,7 +97,7 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... name="Mona", element="Hydro", rarity=5, - icon="https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_Mona.png", + icon="https://enka.network/ui/UI_AvatarIcon_Mona.png", lang="en-us", ), ), @@ -126,14 +126,14 @@ class LiteralCharacter(genshin.models.BaseCharacter): ... "name": "胡桃", "element": "Pyro", "rarity": 5, - "icon": "https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_Hutao.png", + "icon": "https://enka.network/ui/UI_AvatarIcon_Hutao.png", }, LiteralCharacter( id=10000046, name="胡桃", element="Pyro", rarity=5, - icon="https://upload-os-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_Hutao.png", + icon="https://enka.network/ui/UI_AvatarIcon_Hutao.png", lang="en-us", ), ),