Skip to content

Commit

Permalink
feat: Implement gacha-log method (resolve #1)
Browse files Browse the repository at this point in the history
  • Loading branch information
JellyBrick committed Apr 9, 2023
1 parent c4539ee commit a9df521
Show file tree
Hide file tree
Showing 18 changed files with 378 additions and 83 deletions.
1 change: 1 addition & 0 deletions lib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dependencies {
implementation(group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version = "0.4.0")
implementation(group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.5.0")
implementation(group = "com.soywiz.korlibs.krypto", name = "krypto-jvm", version = "2.2.0")
implementation(group = "com.benasher44", name = "uuid", version = "0.7.0")
}

testing {
Expand Down
206 changes: 168 additions & 38 deletions lib/src/main/kotlin/be/zvz/koyo/GenshinImpact.kt
Original file line number Diff line number Diff line change
@@ -1,19 +1,10 @@
package be.zvz.koyo

import be.zvz.koyo.constants.Headers
import be.zvz.koyo.constants.Routes
import be.zvz.koyo.dto.GenshinCharacterIdsAndServerAndRoleId
import be.zvz.koyo.dto.GenshinCharacters
import be.zvz.koyo.dto.GenshinDailyClaim
import be.zvz.koyo.dto.GenshinDailyClaimWrapper
import be.zvz.koyo.dto.GenshinDailyInfo
import be.zvz.koyo.dto.GenshinDailyNote
import be.zvz.koyo.dto.GenshinDailyReward
import be.zvz.koyo.dto.GenshinDailyRewards
import be.zvz.koyo.dto.GenshinDiaryDetail
import be.zvz.koyo.dto.GenshinDiaryInfo
import be.zvz.koyo.dto.GenshinRecord
import be.zvz.koyo.dto.GenshinServerAndRoleId
import be.zvz.koyo.dto.HoyoLabResponse
import be.zvz.koyo.dto.*
import be.zvz.koyo.exception.HoyoLabException
import be.zvz.koyo.types.GachaType
import be.zvz.koyo.types.Games
import be.zvz.koyo.types.Language
import be.zvz.koyo.types.genshin.GenshinAbyssSchedule
Expand All @@ -25,42 +16,32 @@ import be.zvz.koyo.types.genshin.GenshinSpiralAbyss
import be.zvz.koyo.utils.AsyncHandler
import be.zvz.koyo.utils.RequestUtil
import be.zvz.koyo.utils.StringUtil
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import kotlinx.serialization.json.decodeFromStream
import okhttp3.Call
import okhttp3.Callback
import okhttp3.*
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.internal.commonEmptyRequestBody
import okio.IOException

class GenshinImpact @JvmOverloads constructor(
options: GenshinOptions,
private val okHttpClient: OkHttpClient = OkHttpClient(),
) {
@OptIn(ExperimentalSerializationApi::class)
private val jsonParser = Json {
ignoreUnknownKeys = true
namingStrategy = JsonNamingStrategy.SnakeCase
isLenient = true
}
okHttpClient: OkHttpClient = OkHttpClient(),
) : HoyoLab(options, okHttpClient) {
private val cookie = options.cookie
private val uid: Long = requireNotNull(options.uid) { "UID is required" }
private val language = options.language ?: Language.ENGLISH

val userRegion: GenshinRegion = GenshinRegion.from(StringUtil.genshinUidToRegion(options.uid.toString()))

private fun generateRecordCall() = okHttpClient.newCall(
RequestUtil.getDefaultRequestBuilder(cookie)
RequestUtil.getDefaultWebRequestBuilder(cookie)
.url(
Routes.GENSHIN_GAME_RECORD.toHttpUrl()
.newBuilder()
Expand All @@ -84,7 +65,7 @@ class GenshinImpact @JvmOverloads constructor(
)

private fun generateCharactersCall() = okHttpClient.newCall(
RequestUtil.getDefaultRequestBuilder(cookie)
RequestUtil.getDefaultWebRequestBuilder(cookie)
.url(Routes.GENSHIN_CHARACTERS_LIST)
.header("DS", StringUtil.generateDS())
.post(
Expand All @@ -111,7 +92,7 @@ class GenshinImpact @JvmOverloads constructor(
private fun generateCharactersSummary(
characterIds: List<Long>,
) = okHttpClient.newCall(
RequestUtil.getDefaultRequestBuilder(cookie)
RequestUtil.getDefaultWebRequestBuilder(cookie)
.url(Routes.GENSHIN_CHARACTERS_SUMMARY)
.post(
jsonParser.encodeToString(
Expand Down Expand Up @@ -143,7 +124,7 @@ class GenshinImpact @JvmOverloads constructor(
private fun generateSpiralAbyssCall(
scheduleType: GenshinAbyssSchedule,
) = okHttpClient.newCall(
RequestUtil.getDefaultRequestBuilder(cookie)
RequestUtil.getDefaultWebRequestBuilder(cookie)
.url(
Routes.GENSHIN_SPIRAL_ABYSS.toHttpUrl()
.newBuilder()
Expand Down Expand Up @@ -172,7 +153,7 @@ class GenshinImpact @JvmOverloads constructor(
)

private fun generateDailyNoteCall() = okHttpClient.newCall(
RequestUtil.getDefaultRequestBuilder(cookie)
RequestUtil.getDefaultWebRequestBuilder(cookie)
.url(
Routes.GENSHIN_DAILY_NOTE.toHttpUrl()
.newBuilder()
Expand All @@ -197,7 +178,7 @@ class GenshinImpact @JvmOverloads constructor(
)

private fun generateDiariesCall(month: GenshinDiaryMonth) = okHttpClient.newCall(
RequestUtil.getDefaultRequestBuilder(cookie)
RequestUtil.getDefaultWebRequestBuilder(cookie)
.url(
Routes.GENSHIN_DIARY.toHttpUrl()
.newBuilder()
Expand Down Expand Up @@ -237,7 +218,7 @@ class GenshinImpact @JvmOverloads constructor(

do {
val response = okHttpClient.newCall(
RequestUtil.getDefaultRequestBuilder(cookie)
RequestUtil.getDefaultWebRequestBuilder(cookie)
.url(
Routes.GENSHIN_DIARY_DETAIL.toHttpUrl()
.newBuilder()
Expand Down Expand Up @@ -277,7 +258,7 @@ class GenshinImpact @JvmOverloads constructor(
}

private fun generateDailyInfo() = okHttpClient.newCall(
RequestUtil.getDefaultRequestBuilder(cookie)
RequestUtil.getDefaultWebRequestBuilder(cookie)
.url(
Routes.GENSHIN_DAILY_INFO.toHttpUrl()
.newBuilder()
Expand All @@ -302,7 +283,7 @@ class GenshinImpact @JvmOverloads constructor(
)

private fun generateDailyRewards() = okHttpClient.newCall(
RequestUtil.getDefaultRequestBuilder(cookie)
RequestUtil.getDefaultWebRequestBuilder(cookie)
.url(
Routes.GENSHIN_DAILY_REWARD.toHttpUrl()
.newBuilder()
Expand All @@ -327,7 +308,7 @@ class GenshinImpact @JvmOverloads constructor(
)

private fun generateDailyClaim() = okHttpClient.newCall(
RequestUtil.getDefaultRequestBuilder(cookie)
RequestUtil.getDefaultWebRequestBuilder(cookie)
.url(
Routes.GENSHIN_DAILY_CLAIM.toHttpUrl()
.newBuilder()
Expand Down Expand Up @@ -393,7 +374,7 @@ class GenshinImpact @JvmOverloads constructor(
}

private fun generateRedeemCodeCall(redeemCode: String) = okHttpClient.newCall(
RequestUtil.getDefaultRequestBuilder(cookie)
RequestUtil.getDefaultWebRequestBuilder(cookie)
.url(
Routes.GENSHIN_REDEEM_CODE.toHttpUrl()
.newBuilder()
Expand Down Expand Up @@ -437,6 +418,155 @@ class GenshinImpact @JvmOverloads constructor(
},
)

@OptIn(ExperimentalSerializationApi::class)
fun authKey(): HoyoLabAuthKey {
TODO(
"Currently, oversea account (HoyoLab Account) is not support generate gacha Authkey from HoyoLab sToken. " +
"Check this issue https://github.com/DGP-Studio/Snap.Hutao/issues/638 " +
"I don't think it's technically impossible, but I haven't verified that it works. " +
"https://github.com/ctrlcvs/xiaoyao-cvs-plugin/blob/master/model/mys/mihoyoApi.js"
)

val loginByCookieResult = okHttpClient.newCall(
Request.Builder()
.addHeader("User-Agent", Headers.Web.USER_AGENT)
.addHeader("Cookie", RequestUtil.generateCookieFromCookieObject(cookie))
.url(
Routes.LOGIN_BY_COOKIE.toHttpUrl()
.newBuilder()
.addQueryParameter("t", Clock.System.now().epochSeconds.toString())
.build(),
)
.get()
.build(),
).execute().use {
jsonParser.decodeFromStream<HoyoLabWebResponse<HoyoLabLoginByCookie>>(it.body.byteStream()).data
}

val accountInfo =
loginByCookieResult.accountInfo ?: throw HoyoLabException("Invalid data received ($loginByCookieResult)")

val loginToken = okHttpClient.newCall(
Request.Builder()
.addHeader("User-Agent", Headers.Web.USER_AGENT)
.addHeader("Cookie", RequestUtil.generateCookieFromCookieObject(cookie))
.url(
Routes.AUTH_MULTI_TOKEN.toHttpUrl()
.newBuilder()
.addQueryParameter("login_ticket", accountInfo.webLoginToken)
.addQueryParameter("token_types", "3")
.addQueryParameter("uid", accountInfo.accountId.toString())
.build(),
)
.get()
.build(),
).execute().use {
jsonParser.decodeFromStream<HoyoLabResponse<HoyoLabMultiTokenByLoginTicket>>(it.body.byteStream()).data
}

cookie.stuid = accountInfo.accountId
val account = gamesList(Games.GENSHIN_IMPACT_GLOBAL).list.first { it.gameUid == uid }
val ds = StringUtil.generateDS("jEpJb9rRARU2rXDA9qYbZ3selxkuct9a")
val requestBuilder = RequestUtil.getDefaultAndroidRequestBuilder()
.addHeader("DS", ds)
.addHeader(
"Cookie",
RequestUtil.generateCookieFromCookieObject(cookie) + "; " + loginToken.list.joinToString("; ") { ticketToken ->
"${ticketToken.name}=${ticketToken.token}"
},
)
.url(Routes.GENSHIN_AUTHKEY)
.post(
jsonParser.encodeToString(
HoyoLabAuthKeyRequest(
"webview_gacha",
account.gameBiz,
account.gameUid,
account.region,
),
).toRequestBody("application/json".toMediaType()),
)
val genshinAuthKeyResponse = okHttpClient.newCall(requestBuilder.build()).execute().use { response ->
jsonParser.decodeFromStream<HoyoLabResponse<HoyoLabAuthKey?>>(response.body.byteStream())
}

return if (genshinAuthKeyResponse.message == "OK") {
genshinAuthKeyResponse.data
} else {
null
} ?: throw HoyoLabException("Invalid data received ($genshinAuthKeyResponse)")
}

private fun generateWishLogCall(
authKey: HoyoLabAuthKey,
gachaType: GachaType,
language: Language,
page: Long,
endId: Long
) =
okHttpClient.newCall(
RequestUtil.getDefaultWebRequestBuilder(cookie)
.url(
Routes.GENSHIN_WISH.toHttpUrl()
.newBuilder()
.addQueryParameter("win_mode", "fullscreen")
.addQueryParameter("authkey_ver", authKey.authkeyVersion)
.addQueryParameter("sign_type", "2")
.addQueryParameter("auth_appid", "webview_gacha")
.addQueryParameter("init_type", gachaType.code.toString())
.addQueryParameter("lang", language.id)
.addQueryParameter("device_type", "pc")
.addQueryParameter("authkey", authKey.authkey)
.addQueryParameter("region", userRegion.id)
.addQueryParameter("page", page.toString())
.addQueryParameter("end_id", endId.toString())
.build()
)
.build()
)

@JvmOverloads
@OptIn(ExperimentalSerializationApi::class)
fun getWishLog(
authKey: HoyoLabAuthKey,
gachaType: GachaType,
language: Language,
page: Long,
endId: Long = 0
): GenshinWishLog =
generateWishLogCall(authKey, gachaType, language, page, endId).execute().use {
jsonParser.decodeFromStream<HoyoLabResponse<GenshinWishLog>>(it.body.byteStream()).data
}

@JvmOverloads
fun getWishLog(
authKey: HoyoLabAuthKey,
gachaType: GachaType,
language: Language,
page: Long,
endId: Long = 0,
callback: (GenshinWishLog) -> Unit,
exceptionHandler: ((IOException) -> Unit)? = null,
) = generateWishLogCall(authKey, gachaType, language, page, endId).enqueue(
AsyncHandler(jsonParser, GenshinWishLog.serializer(), callback, exceptionHandler)
)

fun getWishLogList(authKey: HoyoLabAuthKey, gachaType: GachaType, language: Language): List<GenshinWishLog> =
mutableListOf<GenshinWishLog>().apply {
var page = 1L
var endId = 0L
var next = true
do {
val wishLog = getWishLog(authKey, gachaType, language, page++, endId)
if (wishLog.list.isNotEmpty()) {
add(wishLog)
endId = wishLog.list.last().id
} else {
next = false
}
} while (next)
}

companion object {
@JvmStatic
fun create(options: GenshinOptions): GenshinImpact {
Expand Down
30 changes: 16 additions & 14 deletions lib/src/main/kotlin/be/zvz/koyo/HoyoLab.kt
Original file line number Diff line number Diff line change
@@ -1,33 +1,35 @@
package be.zvz.koyo

import be.zvz.koyo.constants.Routes
import be.zvz.koyo.dto.GenshinGame
import be.zvz.koyo.dto.HoyoLabGame
import be.zvz.koyo.dto.HoyoLabGameList
import be.zvz.koyo.dto.HoyoLabResponse
import be.zvz.koyo.types.Games
import be.zvz.koyo.types.Language
import be.zvz.koyo.types.hoyolab.HoyoLabOptions
import be.zvz.koyo.utils.AsyncHandler
import be.zvz.koyo.utils.RequestUtil
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import kotlinx.serialization.json.decodeFromStream
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okio.IOException

class HoyoLab @JvmOverloads constructor(
private val options: HoyoLabOptions,
private val okHttpClient: OkHttpClient = OkHttpClient(),
open class HoyoLab @JvmOverloads constructor(
protected val options: HoyoLabOptions,
protected val okHttpClient: OkHttpClient = OkHttpClient(),
) {
@OptIn(ExperimentalSerializationApi::class)
private val jsonParser = Json {
protected val jsonParser = Json {
ignoreUnknownKeys = true
namingStrategy = JsonNamingStrategy.SnakeCase
isLenient = true
}

private fun generateGameListCall(game: Games?) = okHttpClient.newCall(
RequestUtil.getDefaultRequestBuilder(options.cookie)
RequestUtil.getDefaultWebRequestBuilder(options.cookie)
.url(
Routes.GAMES_ACCOUNT.toHttpUrl()
.newBuilder()
Expand All @@ -45,17 +47,17 @@ class HoyoLab @JvmOverloads constructor(
)

@OptIn(ExperimentalSerializationApi::class)
fun gamesList(game: Games? = null): List<GenshinGame> = generateGameListCall(game).execute().use {
jsonParser.decodeFromStream(it.body.byteStream())
fun gamesList(game: Games? = null): HoyoLabGameList = generateGameListCall(game).execute().use {
jsonParser.decodeFromStream<HoyoLabResponse<HoyoLabGameList>>(it.body.byteStream()).data
}

fun gamesList(game: Games? = null, callback: (List<GenshinGame>) -> Unit, exceptionHandler: ((IOException) -> Unit)? = null) {
generateGameListCall(game).enqueue(AsyncHandler(jsonParser, ListSerializer(GenshinGame.serializer()), callback, exceptionHandler))
fun gamesList(game: Games? = null, callback: (HoyoLabGameList) -> Unit, exceptionHandler: ((IOException) -> Unit)? = null) {
generateGameListCall(game).enqueue(AsyncHandler(jsonParser, HoyoLabGameList.serializer(), callback, exceptionHandler))
}

fun gameAccount(game: Games): GenshinGame = gamesList(game).maxBy { it.level }
fun gameAccount(game: Games): HoyoLabGame = gamesList(game).list.maxBy { it.level }

fun gameAccount(game: Games, callback: (GenshinGame) -> Unit, exceptionHandler: ((IOException) -> Unit)? = null) {
gamesList(game, { callback(it.maxBy { element -> element.level }) }, exceptionHandler)
fun gameAccount(game: Games, callback: (HoyoLabGame) -> Unit, exceptionHandler: ((IOException) -> Unit)? = null) {
gamesList(game, { callback(it.list.maxBy { element -> element.level }) }, exceptionHandler)
}
}
Loading

0 comments on commit a9df521

Please sign in to comment.