Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ val modId: String by project
val fabricLoaderVersion: String by project
val kotlinxCoroutinesVersion: String by project
val discordIPCVersion: String by project
val fuelVersion: String by project
val resultVersion: String by project
val ktorVersion: String by project
val mockitoKotlin: String by project
val mockitoInline: String by project
val mockkVersion: String by project
Expand Down Expand Up @@ -50,16 +49,19 @@ dependencies {
implementation("com.github.Edouard127:KDiscordIPC:$discordIPCVersion")
implementation("com.pngencoder:pngencoder:0.15.0")

// Fuel HTTP library and dependencies
implementation("com.github.kittinunf.fuel:fuel:$fuelVersion")
implementation("com.github.kittinunf.fuel:fuel-gson:$fuelVersion")
implementation("com.github.kittinunf.result:result-jvm:$resultVersion")
// Ktor
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-cio:$ktorVersion")
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-gson:$ktorVersion")

// Add Kotlin
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinxCoroutinesVersion")

// Baritone
modImplementation("baritone-api:baritone-unoptimized-fabric:1.10.2") { isTransitive = false }

// Test implementations
testImplementation(kotlin("test"))
testImplementation("org.mockito.kotlin:mockito-kotlin:$mockitoKotlin")
testImplementation("org.mockito:mockito-inline:$mockitoInline")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import com.lambda.brigadier.required
import com.lambda.command.LambdaCommand
import com.lambda.network.CapeManager.updateCape
import com.lambda.network.NetworkManager
import com.lambda.threading.runSafe
import com.lambda.util.Communication.info
import com.lambda.util.Communication.logError
import com.lambda.util.extension.CommandBuilder

object CapeCommand : LambdaCommand(
Expand All @@ -44,9 +45,10 @@ object CapeCommand : LambdaCommand(
}

execute {
runSafe {
val cape = id().value()
updateCape(cape)
val cape = id().value()
updateCape(cape) { error ->
if (error != null) logError("Could not update your cape", error)
else info("Updated your cape to $cape")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,9 @@ object Discord : Module(

val auth = rpc.applicationManager.authenticate()

linkDiscord(discordToken = auth.accessToken,
success = { updateToken(it); discordAuth = auth },
failure = { warn("Failed to link the discord account to the minecraft auth") }
)
linkDiscord(discordToken = auth.accessToken)
.fold(onSuccess = { updateToken(it); discordAuth = auth },
onFailure = { warn("Failed to link the discord account to the minecraft auth") })
}

private fun stop() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,18 @@ object Network : Module(
hash = BigInteger(computed).toString(16)
}

listenUnsafe<ConnectionEvent.Connect.Post> {
listenUnsafeConcurrently<ConnectionEvent.Connect.Post> {
// FixMe: If the player have the properties but are invalid this doesn't work
if (NetworkManager.isValid || mc.gameProfile.isOffline) return@listenUnsafe
if (NetworkManager.isValid || mc.gameProfile.isOffline) return@listenUnsafeConcurrently

// If we log in right as the client responds to the encryption request, we start
// a race condition where the game server haven't acknowledged the packets
// and posted to the sessionserver api
login(mc.session.username, hash ?: return@listenUnsafe,
success = { updateToken(it) },
failure = { LOG.warn("Unable to authenticate: $it") }
)
login(mc.session.username, hash ?: return@listenUnsafeConcurrently)
.fold(
onSuccess = { updateToken(it) },
onFailure = { LOG.warn("Unable to authenticate: $it") }
)
}
}

Expand Down
59 changes: 29 additions & 30 deletions common/src/main/kotlin/com/lambda/network/CapeManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@

package com.lambda.network

import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.core.requests.CancellableRequest
import com.lambda.Lambda.mc
import com.lambda.context.SafeContext
import com.lambda.core.Loadable
Expand All @@ -29,22 +27,20 @@ import com.lambda.network.api.v1.endpoints.getCape
import com.lambda.network.api.v1.endpoints.setCape
import com.lambda.network.api.v1.models.Cape
import com.lambda.sound.SoundManager.toIdentifier
import com.lambda.util.Communication.info
import com.lambda.util.Communication.logError
import com.lambda.threading.runIO
import com.lambda.util.FolderRegister.capes
import com.lambda.util.extension.get
import com.lambda.util.extension.resolveFile
import net.minecraft.client.texture.NativeImage.read
import net.minecraft.client.texture.NativeImageBackedTexture
import java.io.ByteArrayOutputStream
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.extension
import kotlin.io.path.inputStream
import kotlin.io.path.nameWithoutExtension
import kotlin.io.path.walk

@OptIn(ExperimentalPathApi::class)
@Suppress("JavaIoSerializableObjectMustHaveReadResolve")
object CapeManager : ConcurrentHashMap<UUID, String>(), Loadable {
/**
Expand All @@ -57,37 +53,40 @@ object CapeManager : ConcurrentHashMap<UUID, String>(), Loadable {

/**
* Sets the current player's cape
*
* @param block Lambda called once the coroutine completes, it contains the throwable if any
*/
fun SafeContext.updateCape(cape: String): CancellableRequest =
setCape(cape,
success = { fetchCape(player.uuid); info("Successfully update your cape to $cape") },
failure = { logError("Could not update the player cape", it) }
)
fun updateCape(cape: String, block: (Throwable?) -> Unit = {}) = runIO {
setCape(cape).getOrThrow()
}.invokeOnCompletion { block(it) }

/**
* Fetches the cape of the given player id
*
* @param block Lambda called once the coroutine completes, it contains the throwable if any
*/
fun SafeContext.fetchCape(uuid: UUID): CancellableRequest =
getCape(uuid,
success = { mc.textureManager.get(it.identifier) ?: download(it); put(uuid, it.id) },
failure = { logError("Could not fetch the cape of the player", it) }
)
fun SafeContext.fetchCape(uuid: UUID, block: (Throwable?) -> Unit = {}) = runIO {
val cape = getCape(uuid).getOrThrow()

private fun SafeContext.download(cape: Cape): CancellableRequest =
Fuel.download(cape.url)
.fileDestination { _, _ -> capes.resolveFile("${cape.id}.png") }
.response { result ->
result.fold(
success = {
val image = TextureUtils.readImage(it)
val native = NativeImageBackedTexture(image)
val id = cape.identifier
mc.textureManager.get(cape.identifier) ?: download(cape)
put(uuid, cape.id)
}.invokeOnCompletion { block(it) }

mc.textureManager.registerTexture(id, native)
},
failure = { logError("Could not download the cape", it) }
)
}
private fun SafeContext.download(cape: Cape, block: (Throwable?) -> Unit = {}) = runIO {
val destination = capes.resolveFile("${cape.id}.png")
val output = ByteArrayOutputStream()

LambdaHttp.download(cape.url, output)

val bytes = output.toByteArray()
destination.writeBytes(bytes)

val image = TextureUtils.readImage(bytes)
val native = NativeImageBackedTexture(image)
Comment on lines +77 to +85
Copy link

Copilot AI Apr 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Since LambdaHttp provides an overload to write directly to a file, consider using that version instead of writing to a ByteArrayOutputStream and then saving the bytes manually.

Copilot uses AI. Check for mistakes.
val id = cape.identifier

mc.textureManager.registerTexture(id, native)
}.invokeOnCompletion { block(it) }

override fun load() = "Loaded ${images.size} cached capes"

Expand Down
44 changes: 44 additions & 0 deletions common/src/main/kotlin/com/lambda/network/LambdaHttp.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright 2025 Lambda
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package com.lambda.network

import com.lambda.Lambda
import io.ktor.client.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.gson.*
import java.io.File
import java.io.OutputStream

val LambdaHttp = HttpClient {
install(ContentNegotiation) {
// Use our gson instance
register(ContentType.Application.Json, GsonConverter(Lambda.gson))
}
}

suspend inline fun HttpClient.download(url: String, file: File, block: HttpRequestBuilder.() -> Unit = {}) =
file.writeBytes(get(url, block).readRawBytes())

suspend inline fun HttpClient.download(url: String, output: OutputStream, block: HttpRequestBuilder.() -> Unit = {}) =
output.write(get(url, block).readRawBytes())

suspend inline fun HttpClient.download(url: String, block: HttpRequestBuilder.() -> Unit) =
get(url, block).readRawBytes()
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,12 @@

package com.lambda.network.api.v1.endpoints

import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.core.FuelError
import com.github.kittinunf.fuel.core.ResultHandler
import com.github.kittinunf.fuel.gson.responseObject
import com.github.kittinunf.result.failure
import com.github.kittinunf.result.success
import com.lambda.module.modules.client.Network.apiUrl
import com.lambda.module.modules.client.Network.apiVersion
import com.lambda.network.LambdaHttp
import com.lambda.network.api.v1.models.Cape
import io.ktor.client.call.*
import io.ktor.client.request.*
import java.util.UUID

/**
Expand All @@ -36,6 +33,6 @@ import java.util.UUID
*
* response: [Cape] or error
*/
fun getCape(uuid: UUID, success: (Cape) -> Unit, failure: (FuelError) -> Unit) =
Fuel.get("$apiUrl/api/${apiVersion.value}/cape?id=$uuid")
.responseObject<Cape> { _, _, result -> result.fold(success, failure) }
suspend fun getCape(uuid: UUID) = runCatching {
LambdaHttp.get("$apiUrl/api/${apiVersion.value}/cape?id=$uuid").body<Cape>()
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,14 @@

package com.lambda.network.api.v1.endpoints

import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.core.FuelError
import com.github.kittinunf.fuel.core.extensions.authentication
import com.github.kittinunf.fuel.core.extensions.jsonBody
import com.github.kittinunf.fuel.gson.responseObject
import com.lambda.module.modules.client.Network.apiUrl
import com.lambda.module.modules.client.Network.apiVersion
import com.lambda.network.LambdaHttp
import com.lambda.network.NetworkManager
import com.lambda.network.api.v1.models.Authentication
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*

/**
* Links a Discord account to a session account
Expand All @@ -35,9 +34,10 @@ import com.lambda.network.api.v1.models.Authentication
*
* response: [Authentication] or error
*/
fun linkDiscord(discordToken: String, success: (Authentication) -> Unit, failure: (FuelError) -> Unit) =
Fuel.post("${apiUrl}/api/${apiVersion.value}/link/discord")
.jsonBody("""{ "token": "$discordToken" }""")
.authentication()
.bearer(NetworkManager.accessToken)
.responseObject<Authentication> { _, _, result -> result.fold(success, failure) }
suspend fun linkDiscord(discordToken: String) = runCatching {
LambdaHttp.post("${apiUrl}/api/${apiVersion.value}/link/discord") {
setBody("""{ "token": "$discordToken" }""")
bearerAuth(NetworkManager.accessToken)
contentType(ContentType.Application.Json)
}.body<Authentication>()
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@

package com.lambda.network.api.v1.endpoints

import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.core.FuelError
import com.github.kittinunf.fuel.core.extensions.jsonBody
import com.github.kittinunf.fuel.gson.responseObject
import com.lambda.module.modules.client.Network.apiUrl
import com.lambda.module.modules.client.Network.apiVersion
import com.lambda.network.LambdaHttp
import com.lambda.network.api.v1.models.Authentication
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*

/**
* Creates a new session account with mojang session hashes
Expand All @@ -34,7 +34,9 @@ import com.lambda.network.api.v1.models.Authentication
*
* response: [Authentication] or error
*/
fun login(username: String, hash: String, success: (Authentication) -> Unit, failure: (FuelError) -> Unit) =
Fuel.post("${apiUrl}/api/${apiVersion.value}/login")
.jsonBody("""{ "username": "$username", "hash": "$hash" }""")
.responseObject<Authentication> { _, _, result -> result.fold(success, failure) }
suspend fun login(username: String, hash: String) = runCatching {
LambdaHttp.post("${apiUrl}/api/${apiVersion.value}/login") {
setBody("""{ "username": "$username", "hash": "$hash" }""")
contentType(ContentType.Application.Json)
}.body<Authentication>()
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,12 @@

package com.lambda.network.api.v1.endpoints

import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.core.FuelError
import com.github.kittinunf.fuel.core.awaitResult
import com.github.kittinunf.fuel.core.extensions.authentication
import com.lambda.module.modules.client.Network.apiUrl
import com.lambda.module.modules.client.Network.apiVersion
import com.lambda.network.LambdaHttp
import com.lambda.network.NetworkManager
import io.ktor.client.request.*
import io.ktor.http.*

/**
* Sets the currently authenticated player's cape
Expand All @@ -33,8 +32,11 @@ import com.lambda.network.NetworkManager
*
* response: [Unit] or error
*/
fun setCape(id: String, success: (ByteArray) -> Unit, failure: (FuelError) -> Unit) =
Fuel.put("$apiUrl/api/${apiVersion.value}/cape?id=$id")
.authentication()
.bearer(NetworkManager.accessToken)
.response { _, _, resp -> resp.fold(success, failure) }
suspend fun setCape(id: String) = runCatching {
val resp = LambdaHttp.put("$apiUrl/api/${apiVersion.value}/cape?id=$id") {
bearerAuth(NetworkManager.accessToken)
contentType(ContentType.Application.Json)
}

check(resp.status == HttpStatusCode.OK)
Copy link

Copilot AI Apr 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using 'check' to validate the response status may obscure error details if the request fails. Consider returning a Result with a descriptive error or throwing a custom exception that provides more context.

Copilot uses AI. Check for mistakes.
}
Loading
Loading