Skip to content

Commit

Permalink
Add HoYoLab geetest handling (#188)
Browse files Browse the repository at this point in the history
  • Loading branch information
jokelbaf authored May 31, 2024
1 parent 69f82a0 commit 76afc28
Show file tree
Hide file tree
Showing 9 changed files with 98 additions and 52 deletions.
50 changes: 37 additions & 13 deletions genshin/client/components/auth/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import aiohttp

from genshin import errors, types
from genshin import constants, errors, types
from genshin.client import routes
from genshin.client.components import base
from genshin.client.manager import managers
Expand All @@ -19,10 +19,9 @@
QRLoginResult,
WebLoginResult,
)
from genshin.models.auth.geetest import MMT, RiskyCheckMMT, RiskyCheckMMTResult, SessionMMT, SessionMMTResult
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.types import Game
from genshin.utility import auth as auth_utility
from genshin.utility import ds as ds_utility

Expand Down Expand Up @@ -273,31 +272,56 @@ async def login_with_qrcode(self) -> QRLoginResult:
self.set_cookies(cookies)
return QRLoginResult(**cookies)

@base.region_specific(types.Region.CHINESE)
@managers.no_multi
async def create_mmt(self) -> MMT:
"""Create a geetest challenge."""
is_genshin = self.game is Game.GENSHIN
if self.default_game is None:
raise ValueError("No default game set.")

headers = {
"DS": ds_utility.generate_create_geetest_ds(),
"x-rpc-challenge_game": "2" if is_genshin else "6",
"x-rpc-page": "v4.1.5-ys_#ys" if is_genshin else "v1.4.1-rpg_#/rpg",
"x-rpc-tool-verison": "v4.1.5-ys" if is_genshin else "v1.4.1-rpg",
**auth_utility.CREATE_MMT_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(
routes.CREATE_MMT_URL.get_url(), headers=headers, cookies=self.cookie_manager.cookies
) as r:
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,
Expand Down
12 changes: 6 additions & 6 deletions genshin/client/components/auth/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

__all__ = ["PAGES", "enter_code", "launch_webapp", "solve_geetest"]

PAGES: typing.Final[typing.Dict[typing.Literal["captcha", "captcha-v4", "enter-code"], str]] = {
PAGES: typing.Final[typing.Dict[typing.Literal["captcha", "enter-code"], str]] = {
"captcha": """
<!DOCTYPE html>
<head>
Expand All @@ -44,25 +44,25 @@
gt: mmt.gt,
challenge: mmt.challenge,
new_captcha: mmt.new_captcha,
api_server: '{api_server}',
api_server: "{api_server}",
https: /^https/i.test(window.location.protocol),
product: "bind",
lang: '{lang}',
lang: "{lang}",
} : {
captchaId: mmt.gt,
riskType: mmt.risk_type,
userInfo: mmt.session_id ? JSON.stringify({
mmt_key: mmt.session_id
}) : undefined,
api_server: '{api_server}',
api_server: "{api_server}",
product: "bind",
language: '{lang}',
language: "{lang}",
};
initGeetest(
initParams,
(captcha) => {
captcha.onReady(() => {
captcha.verify();
geetestVersion == 3 ? captcha.verify() : captcha.showCaptcha();
});
captcha.onSuccess(() => {
fetch("/send-data", {
Expand Down
7 changes: 1 addition & 6 deletions genshin/client/manager/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@
from genshin.client import ratelimit
from genshin.utility import fs as fs_utility

from ...constants import MIYOUSHE_GEETEST_RETCODES

_LOGGER = logging.getLogger(__name__)

__all__ = [
Expand Down Expand Up @@ -153,10 +151,7 @@ async def _request(
cookies.update(new_cookies)
_LOGGER.debug("Updating cookies for %s: %s", get_cookie_identifier(cookies), new_keys)

errors.check_for_geetest(data)

if data["retcode"] in MIYOUSHE_GEETEST_RETCODES:
raise errors.MiyousheGeetestError(data, {k: morsel.value for k, morsel in response.cookies.items()})
errors.check_for_geetest(data, {k: morsel.value for k, morsel in response.cookies.items()})

if data["retcode"] == 0:
return data["data"]
Expand Down
8 changes: 6 additions & 2 deletions genshin/client/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"TAKUMI_URL",
"TEAPOT_URL",
"VERIFY_EMAIL_URL",
"VERIFY_MMT_URL",
"WEBAPI_URL",
"WEBSTATIC_URL",
"WEB_LOGIN_URL",
Expand Down Expand Up @@ -239,10 +240,13 @@ def get_url(self, region: types.Region, game: types.Game) -> yarl.URL:
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 = Route(
"https://api-takumi-record.mihoyo.com/game_record/app/card/wapi/createVerification?is_high=false"
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",
Expand Down
14 changes: 11 additions & 3 deletions genshin/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from . import types

__all__ = ["LANGS"]
__all__ = ["APP_IDS", "APP_KEYS", "DS_SALT", "GEETEST_RETCODES", "LANGS"]


LANGS = {
Expand Down Expand Up @@ -33,8 +33,8 @@
}
"""Dynamic Secret Salts."""

MIYOUSHE_GEETEST_RETCODES = {10035, 5003, 10041, 1034}
"""API error codes that indicate a Geetest was triggered during this Miyoushe API request."""
GEETEST_RETCODES = {10035, 5003, 10041, 1034}
"""API error codes that indicate a Geetest was triggered during the API request."""

APP_KEYS = {
types.Game.GENSHIN: {
Expand Down Expand Up @@ -75,3 +75,11 @@
},
}
"""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."""
29 changes: 17 additions & 12 deletions genshin/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@

import typing

from genshin.constants import GEETEST_RETCODES

__all__ = [
"ERRORS",
"AccountNotFound",
"AlreadyClaimed",
"AuthkeyException",
"AuthkeyTimeout",
"CookieException",
"DailyGeetestTriggered",
"DataNotPublic",
"GeetestTriggered",
"GeetestError",
"GenshinException",
"InvalidAuthkey",
"InvalidCookies",
"MiyousheGeetestError",
"RedemptionClaimed",
"RedemptionCooldown",
"RedemptionException",
Expand Down Expand Up @@ -114,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."

Expand Down Expand Up @@ -187,8 +189,8 @@ class WrongOTP(GenshinException):
msg = "The provided OTP code is wrong."


class MiyousheGeetestError(GenshinException):
"""Geetest triggered during Miyoushe API request."""
class GeetestError(GenshinException):
"""Geetest triggered during the battle chronicle API request."""

def __init__(
self,
Expand All @@ -198,7 +200,7 @@ def __init__(
self.cookies = cookies
super().__init__(response)

msg = "Geetest triggered during Miyoushe API request."
msg = "Geetest triggered during the battle chronicle API request."


class OTPRateLimited(GenshinException):
Expand Down Expand Up @@ -340,17 +342,20 @@ 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], cookies: typing.Mapping[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, cookies)

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
and gt_result.get("gt")
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"))
17 changes: 14 additions & 3 deletions genshin/utility/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import typing
from hashlib import sha256

from genshin import constants
from genshin import constants, types

__all__ = ["encrypt_credentials", "generate_sign"]

Expand Down Expand Up @@ -70,8 +70,19 @@
}

CREATE_MMT_HEADERS = {
"x-rpc-app_version": "2.60.1",
"x-rpc-client_type": "5",
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"
Expand Down
9 changes: 4 additions & 5 deletions genshin/utility/ds.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@

__all__ = [
"generate_cn_dynamic_secret",
"generate_create_geetest_ds",
"generate_dynamic_secret",
"generate_geetest_ds",
"generate_passport_ds",
"get_ds_headers",
]
Expand Down Expand Up @@ -78,10 +78,9 @@ def generate_passport_ds(body: typing.Mapping[str, typing.Any]) -> str:
return result


def generate_create_geetest_ds() -> str:
"""Create a dynamic secret for Miyoushe createVerification API endpoint."""
salt = constants.DS_SALT[types.Region.CHINESE]
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={salt}&t={t}&r={r}&b=&q=is_high=false".encode()).hexdigest()
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}"
4 changes: 2 additions & 2 deletions tests/client/components/test_daily.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down

0 comments on commit 76afc28

Please sign in to comment.