Introduce event dispatcher and fix heartbeat, auth reply events
This commit is contained in:
34 files changed
+686
-345
No files matched your search
@@ -45,4 +45,3 @@ bin/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
/.idea/
|
/.idea/
|
||||||
/bl-client/src/commonTest/resources/cookie.txt
|
/bl-client/src/commonTest/resources/cookie.txt
|
||||||
/bl-client/src/commonTest/resources/res.txt
|
|
||||||
@@ -18,10 +18,16 @@ Supported platforms:
|
|||||||
|
|
||||||
[TestClient.kt](bl-client/src/commonTest/kotlin/test/TestClient.kt)
|
[TestClient.kt](bl-client/src/commonTest/kotlin/test/TestClient.kt)
|
||||||
|
|
||||||
|
> `bl-module` is a module for testing parsing and provides usage instructions.
|
||||||
|
> The code is for reference only
|
||||||
|
|
||||||
## Raw parser
|
## Raw parser
|
||||||
|
|
||||||
[bldm.kt](bl-client/src/commonMain/kotlin/cn/rtast/bldm/client/bldm.kt)
|
[bldm.kt](bl-client/src/commonMain/kotlin/cn/rtast/bldm/client/bldm.kt)
|
||||||
|
|
||||||
|
> The parser strictly operates at the packet-framing layer.
|
||||||
|
> It strips the protocol packet and exposes the contained payload as a raw JSON string
|
||||||
|
|
||||||
# Open Source
|
# Open Source
|
||||||
|
|
||||||
Licensed under [Apache-2.0](LICENSE)
|
Licensed under [Apache-2.0](LICENSE)
|
||||||
@@ -17,11 +17,15 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
val ktorVersion = "3.5.2"
|
val ktorVersion = "3.5.2"
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
api(project(":bl-codec"))
|
api(project(":bl-model"))
|
||||||
implementation("io.ktor:ktor-client-core:$ktorVersion")
|
implementation("io.ktor:ktor-client-core:$ktorVersion")
|
||||||
implementation("io.ktor:ktor-client-websockets:$ktorVersion")
|
implementation("io.ktor:ktor-client-websockets:$ktorVersion")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
jvmMain.dependencies {
|
||||||
|
implementation("io.ktor:ktor-client-java:${ktorVersion}")
|
||||||
|
}
|
||||||
|
|
||||||
appleMain.dependencies {
|
appleMain.dependencies {
|
||||||
implementation("io.ktor:ktor-client-darwin:${ktorVersion}")
|
implementation("io.ktor:ktor-client-darwin:${ktorVersion}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,55 +4,54 @@
|
|||||||
* Date: 2026/8/30
|
* Date: 2026/8/30
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
@file:OptIn(InternalBldmApi::class)
|
|
||||||
|
|
||||||
package cn.rtast.bldm.client
|
package cn.rtast.bldm.client
|
||||||
|
|
||||||
import cn.rtast.bldm.codec.BLDMConstants
|
import cn.rtast.bldm.client.util.fromJson
|
||||||
import cn.rtast.bldm.codec.annotations.InternalBldmApi
|
import cn.rtast.bldm.client.util.sendPacket
|
||||||
import cn.rtast.bldm.codec.data.DMServerConf
|
import cn.rtast.bldm.codec.event.DanmuEventDispatcher
|
||||||
import cn.rtast.bldm.codec.data.RealRoomId
|
|
||||||
import cn.rtast.bldm.codec.data.UserNavData
|
|
||||||
import cn.rtast.bldm.codec.protocol.Packet
|
|
||||||
import cn.rtast.bldm.codec.protocol.codec.PacketDecoder
|
import cn.rtast.bldm.codec.protocol.codec.PacketDecoder
|
||||||
import cn.rtast.bldm.codec.protocol.codec.PacketEncoder
|
import cn.rtast.bldm.codec.protocol.codec.PacketEncoder
|
||||||
import cn.rtast.bldm.codec.protocol.event.PacketEvents
|
|
||||||
import cn.rtast.bldm.codec.util._fromJson
|
|
||||||
import cn.rtast.bldm.codec.util.parseBuvidAndUid
|
import cn.rtast.bldm.codec.util.parseBuvidAndUid
|
||||||
|
import cn.rtast.bldm.codec.util.signWbi
|
||||||
|
import cn.rtast.bldm.dto.BldmConstant
|
||||||
|
import cn.rtast.bldm.dto.DMServerConf
|
||||||
|
import cn.rtast.bldm.dto.RealRoomId
|
||||||
|
import cn.rtast.bldm.dto.UserNavData
|
||||||
import io.ktor.client.plugins.websocket.*
|
import io.ktor.client.plugins.websocket.*
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.statement.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.websocket.*
|
import io.ktor.websocket.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
public class BldmClient internal constructor(
|
||||||
|
private val roomId: Long,
|
||||||
|
private val cookie: String? = null,
|
||||||
|
) : DanmuEventDispatcher(), AutoCloseable {
|
||||||
|
private companion object {
|
||||||
private val HEARTBEAT_INTERVAL = 30.seconds
|
private val HEARTBEAT_INTERVAL = 30.seconds
|
||||||
|
}
|
||||||
|
|
||||||
public suspend fun connectToBlDM(
|
private val packetEncoder = PacketEncoder()
|
||||||
roomId: Long,
|
private val decoder = PacketDecoder(this)
|
||||||
cookie: String?,
|
private val clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
events: PacketEvents.() -> Unit,
|
private var connectionJob: Job? = null
|
||||||
) {
|
private var session: DefaultClientWebSocketSession? = null
|
||||||
|
|
||||||
|
private suspend fun internalConnect() {
|
||||||
val parsedCookie = parseBuvidAndUid(cookie ?: "")
|
val parsedCookie = parseBuvidAndUid(cookie ?: "")
|
||||||
val packetEncoder = PacketEncoder()
|
val realRoomId = httpClient.get(BldmConstant.REAL_ROOM_ID_URL + "?id=$roomId")
|
||||||
val packetDecoder = PacketDecoder(events)
|
.bodyAsText().fromJson<RealRoomId>()
|
||||||
val realRoomId = httpClient.get(BLDMConstants.REAL_ROOM_ID_URL + roomId)
|
val navData = httpClient.get(BldmConstant.USER_NAV_URL)
|
||||||
.bodyAsText()._fromJson<RealRoomId>()
|
.bodyAsText().fromJson<UserNavData>().data.wbiImg
|
||||||
val wbi = httpClient.get(BLDMConstants.USER_NAV_URL)
|
val wbi = signWbi(realRoomId.data.roomId, navData.imgUrl, navData.subUrl)
|
||||||
.bodyAsText()._fromJson<UserNavData>().signWbi(realRoomId.data.roomId)
|
val serverConf = httpClient.get(BldmConstant.DM_SERVER_CONF_URL + "?$wbi") {
|
||||||
val serverConf = httpClient.get(BLDMConstants.DM_SERVER_CONF_URL + "?$wbi") {
|
|
||||||
cookie?.let { header(HttpHeaders.Cookie, it) }
|
cookie?.let { header(HttpHeaders.Cookie, it) }
|
||||||
}.bodyAsText()._fromJson<DMServerConf>().data
|
}.bodyAsText().fromJson<DMServerConf>().data
|
||||||
val wsUrl = "ws://${serverConf.hostList.first().host}:${serverConf.hostList.first().wsPort}/sub"
|
|
||||||
|
|
||||||
httpClient.webSocket(wsUrl, {
|
httpClient.webSocket(serverConf.hostList.first().tlsWsAddress) {
|
||||||
// header(HttpHeaders.Host, "${serverConf.hostList.first().host}:${serverConf.hostList.first().wssPort}")
|
session = this
|
||||||
// header(HttpHeaders.Origin, "https://www.bilibili.com")
|
|
||||||
}) {
|
|
||||||
val authPacket = packetEncoder.authPacket(
|
val authPacket = packetEncoder.authPacket(
|
||||||
parsedCookie.second ?: 0,
|
parsedCookie.second ?: 0,
|
||||||
realRoomId.data.roomId,
|
realRoomId.data.roomId,
|
||||||
@@ -67,25 +66,46 @@ public suspend fun connectToBlDM(
|
|||||||
runCatching {
|
runCatching {
|
||||||
sendPacket(heartbeatPacket)
|
sendPacket(heartbeatPacket)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
println("heartbeat failed ${it.message}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (frame in incoming) {
|
for (frame in incoming) {
|
||||||
if (frame !is Frame.Binary) continue
|
if (frame !is Frame.Binary) continue
|
||||||
val data = frame.readBytes()
|
val data = frame.readBytes()
|
||||||
packetDecoder.decode(data)
|
decoder.decode(data)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
if (session?.isActive == true) e.printStackTrace()
|
||||||
} finally {
|
} finally {
|
||||||
heartbeatJob.cancel()
|
heartbeatJob.cancel()
|
||||||
println("Closed")
|
session = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
httpClient.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal suspend fun DefaultWebSocketSession.sendPacket(packet: Packet) =
|
public fun connect() {
|
||||||
send(Frame.Binary(true, packet.toByteArray()))
|
connectionJob = clientScope.launch { internalConnect() }
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun connectBlocking(): Unit = runBlocking { internalConnect() }
|
||||||
|
|
||||||
|
public suspend fun disconnect(reason: CloseReason = CloseReason(CloseReason.Codes.NORMAL, "")) {
|
||||||
|
session?.close(reason)
|
||||||
|
session = null
|
||||||
|
connectionJob?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun disconnectBlocking(reason: CloseReason = CloseReason(CloseReason.Codes.NORMAL, "")): Unit =
|
||||||
|
runBlocking { disconnect(reason) }
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
runBlocking { disconnect() }
|
||||||
|
clientScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("FunctionName")
|
||||||
|
public fun BLDMClient(roomId: Long, cookie: String? = null): BldmClient =
|
||||||
|
BldmClient(roomId, cookie)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* Copyright © 2026 RTAkland
|
||||||
|
* Author: RTAkland
|
||||||
|
* Date: 2026/8/31
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:Suppress("ObjectPropertyName", "FunctionName")
|
||||||
|
|
||||||
|
package cn.rtast.bldm.client.util
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
internal val _json: Json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
internal inline fun <reified T> String.fromJson(): T = _json.decodeFromString(this)
|
||||||
|
|
||||||
|
internal inline fun <reified T> T.encodeJson(): String = _json.encodeToString(this)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
* Copyright © 2026 RTAkland
|
||||||
|
* Author: RTAkland
|
||||||
|
* Date: 2026/8/31
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
package cn.rtast.bldm.client.util
|
||||||
|
|
||||||
|
import cn.rtast.bldm.codec.protocol.Packet
|
||||||
|
import io.ktor.websocket.DefaultWebSocketSession
|
||||||
|
import io.ktor.websocket.Frame
|
||||||
|
|
||||||
|
internal suspend fun DefaultWebSocketSession.sendPacket(packet: Packet) =
|
||||||
|
send(Frame.Binary(true, packet.toByteArray()))
|
||||||
@@ -7,13 +7,16 @@
|
|||||||
|
|
||||||
package test
|
package test
|
||||||
|
|
||||||
import cn.rtast.bldm.client.connectToBlDM
|
import cn.rtast.bldm.client.BLDMClient
|
||||||
|
import cn.rtast.bldm.codec.event.DanmuEvent
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.io.buffered
|
import kotlinx.io.buffered
|
||||||
import kotlinx.io.files.Path
|
import kotlinx.io.files.Path
|
||||||
import kotlinx.io.files.SystemFileSystem
|
import kotlinx.io.files.SystemFileSystem
|
||||||
import kotlinx.io.readString
|
import kotlinx.io.readString
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class TestClient {
|
class TestClient {
|
||||||
|
|
||||||
@@ -22,18 +25,15 @@ class TestClient {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test bldm client`() = runTest {
|
fun `test bldm client`() = runTest {
|
||||||
connectToBlDM(6, cookie) {
|
val client = BLDMClient(7777, cookie)
|
||||||
onHeartbeat {
|
client.on<DanmuEvent.HeartbeatEvent> { println("Heartbeat") }
|
||||||
println("Heartbeat")
|
client.on<DanmuEvent.AuthReplyEvent> { println("AuthReply") }
|
||||||
}
|
client.on<DanmuEvent.MessageEvent> { println(it.content) }
|
||||||
|
client.connectBlocking()
|
||||||
|
|
||||||
onMessage {
|
// remove lines below if you use client.connectBlocking()
|
||||||
println(it.content)
|
// while (true) {
|
||||||
}
|
// delay(1.seconds)
|
||||||
|
// }
|
||||||
onAuthReply {
|
|
||||||
println("auth reply")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,9 +16,7 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.9.1")
|
||||||
api("org.kotlincrypto.hash:md:0.8.0")
|
|
||||||
api("org.jetbrains.kotlinx:kotlinx-io-core:0.9.1")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright © 2026 RTAkland
|
|
||||||
* Author: RTAkland
|
|
||||||
* Date: 2026/8/30
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
package cn.rtast.bldm.codec.annotations
|
|
||||||
|
|
||||||
@RequiresOptIn(level = RequiresOptIn.Level.ERROR)
|
|
||||||
@Retention(AnnotationRetention.BINARY)
|
|
||||||
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY)
|
|
||||||
public annotation class InternalBldmApi
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright © 2026 RTAkland
|
|
||||||
* Author: RTAkland
|
|
||||||
* Date: 2026/8/30
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalUnsignedTypes::class)
|
|
||||||
|
|
||||||
package cn.rtast.bldm.codec.data
|
|
||||||
|
|
||||||
import cn.rtast.bldm.codec.util.digest
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlin.time.Clock
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
public data class UserNavData(
|
|
||||||
val data: NavData,
|
|
||||||
) {
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
public data class NavData(
|
|
||||||
@SerialName("wbi_img")
|
|
||||||
val wbiImg: WbiImg,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
public data class WbiImg(
|
|
||||||
@SerialName("img_url")
|
|
||||||
val imgUrl: String,
|
|
||||||
@SerialName("sub_url")
|
|
||||||
val subUrl: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val MIXIN_KEY_ENC_TAB = intArrayOf(
|
|
||||||
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
|
|
||||||
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
|
|
||||||
61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
|
|
||||||
36, 20, 34, 44, 52
|
|
||||||
)
|
|
||||||
|
|
||||||
fun String.encodeURIComponent(): String {
|
|
||||||
val bytes = this.encodeToByteArray()
|
|
||||||
val sb = StringBuilder()
|
|
||||||
for (b in bytes) {
|
|
||||||
val c = b.toInt().and(0xFF).toChar()
|
|
||||||
if (c in 'A'..'Z' || c in 'a'..'z' || c in '0'..'9' || c == '-' || c == '_' || c == '.' || c == '~') {
|
|
||||||
sb.append(c)
|
|
||||||
} else {
|
|
||||||
val hex = (b.toInt() and 0xFF).toString(16).uppercase()
|
|
||||||
sb.append('%')
|
|
||||||
if (hex.length == 1) sb.append('0')
|
|
||||||
sb.append(hex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sb.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Map<String, Any?>.toQueryString(): String {
|
|
||||||
return this.mapNotNull { (key, value) ->
|
|
||||||
if (value != null) {
|
|
||||||
"${key.encodeURIComponent()}=${value.toString().encodeURIComponent()}"
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}.joinToString("&")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get resorted mixin key
|
|
||||||
*/
|
|
||||||
private fun getMixinKey(): String =
|
|
||||||
(data.wbiImg.imgUrl.substringAfterLast('/').removeSuffix(".png") +
|
|
||||||
data.wbiImg.subUrl.substringAfterLast('/').removeSuffix(".png")).let { s ->
|
|
||||||
buildString { repeat(32) { append(s[MIXIN_KEY_ENC_TAB[it]]) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun signWbi(room: Long): String {
|
|
||||||
// val payload = _wbi_sign_payload(room, 0, Clock.System.now().epochSeconds) // ref
|
|
||||||
val wts = Clock.System.now().epochSeconds
|
|
||||||
val params = mapOf("id" to room, "type" to 0)
|
|
||||||
.entries.sortedBy { it.key }.associate { it.key to it.value }.toMutableMap()
|
|
||||||
return buildString {
|
|
||||||
append(params.toQueryString())
|
|
||||||
params["wts"] = wts
|
|
||||||
append("&wts=$wts")
|
|
||||||
append("&w_rid=${(params.toQueryString() + getMixinKey()).digest()}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,9 +7,6 @@
|
|||||||
|
|
||||||
package cn.rtast.bldm.codec.data.protocol
|
package cn.rtast.bldm.codec.data.protocol
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
internal data class AuthPayload(
|
internal data class AuthPayload(
|
||||||
/**
|
/**
|
||||||
* user id
|
* user id
|
||||||
@@ -18,12 +15,12 @@ internal data class AuthPayload(
|
|||||||
/**
|
/**
|
||||||
* room id
|
* room id
|
||||||
*/
|
*/
|
||||||
val roomid: Long,
|
val roomId: Long,
|
||||||
/**
|
/**
|
||||||
* protocol version
|
* protocol version
|
||||||
* always be 2
|
* always be 2
|
||||||
*/
|
*/
|
||||||
val protover: Int,
|
val protocolVersion: Int,
|
||||||
/**
|
/**
|
||||||
* device id
|
* device id
|
||||||
*/
|
*/
|
||||||
@@ -39,5 +36,24 @@ internal data class AuthPayload(
|
|||||||
/**
|
/**
|
||||||
* token
|
* token
|
||||||
*/
|
*/
|
||||||
val key: String
|
val key: String,
|
||||||
)
|
) {
|
||||||
|
override fun toString(): String =
|
||||||
|
"""{"uid":$uid,
|
||||||
|
|"roomid":$roomId,
|
||||||
|
|"protover":$protocolVersion,
|
||||||
|
|"buvid":"${buvid.escapeJson()}",
|
||||||
|
|"platform":"${platform.escapeJson()}",
|
||||||
|
|"type":$type,
|
||||||
|
|"key":"${key.escapeJson()}"
|
||||||
|
|}""".trimMargin()
|
||||||
|
|
||||||
|
private fun String.escapeJson(): String = this
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
.replace("\b", "\\b")
|
||||||
|
.replace("\u000C", "\\f")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
.replace("\t", "\\t")
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright © 2026 RTAkland
|
|
||||||
* Author: RTAkland
|
|
||||||
* Date: 2026/8/30
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
package cn.rtast.bldm.codec.data.protocol
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
public data class PacketMetadata(
|
|
||||||
val packetLength: Int,
|
|
||||||
val protocolType: Int,
|
|
||||||
val opCode: Int
|
|
||||||
)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright © 2026 RTAkland
|
|
||||||
* Author: RTAkland
|
|
||||||
* Date: 2026/8/30
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
package cn.rtast.bldm.codec.data.protocol
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
public data class RawDanmuMessage(val content: String)
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* Copyright © 2026 RTAkland
|
||||||
|
* Author: RTAkland
|
||||||
|
* Date: 2026/8/31
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
package cn.rtast.bldm.codec.event
|
||||||
|
|
||||||
|
public sealed interface DanmuEvent {
|
||||||
|
public data object AuthReplyEvent : DanmuEvent
|
||||||
|
public data object HeartbeatEvent : DanmuEvent
|
||||||
|
public data class MessageEvent(val content: String) : DanmuEvent
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* Copyright © 2026 RTAkland
|
||||||
|
* Author: RTAkland
|
||||||
|
* Date: 2026/8/31
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
package cn.rtast.bldm.codec.event
|
||||||
|
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
public open class DanmuEventDispatcher {
|
||||||
|
@PublishedApi
|
||||||
|
internal val eventHandlers: MutableMap<KClass<out DanmuEvent>, MutableList<suspend (DanmuEvent) -> Unit>> =
|
||||||
|
mutableMapOf()
|
||||||
|
|
||||||
|
public suspend fun dispatch(event: DanmuEvent) {
|
||||||
|
eventHandlers[event::class]?.forEach { it.invoke(event) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public inline fun <reified T : DanmuEvent> on(crossinline block: (T) -> Unit) {
|
||||||
|
val handlers = eventHandlers.getOrPut(T::class) { mutableListOf() }
|
||||||
|
handlers.add { event -> block(event as T) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,9 @@ public data class Packet(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encoding to [ByteArray]
|
||||||
|
*/
|
||||||
public fun toByteArray(): ByteArray = Buffer().apply {
|
public fun toByteArray(): ByteArray = Buffer().apply {
|
||||||
writeInt(16 + body.size)
|
writeInt(16 + body.size)
|
||||||
writeShort(headerLength)
|
writeShort(headerLength)
|
||||||
|
|||||||
@@ -7,12 +7,45 @@
|
|||||||
|
|
||||||
package cn.rtast.bldm.codec.protocol
|
package cn.rtast.bldm.codec.protocol
|
||||||
|
|
||||||
|
/**
|
||||||
|
* packet layer type
|
||||||
|
* also represents opcode
|
||||||
|
*/
|
||||||
public enum class PacketType(public val pkCode: Int) {
|
public enum class PacketType(public val pkCode: Int) {
|
||||||
|
/**
|
||||||
|
* auth packet type
|
||||||
|
* serverbound
|
||||||
|
*/
|
||||||
AUTH(7),
|
AUTH(7),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* server reply auth packet
|
||||||
|
* clientbound
|
||||||
|
*/
|
||||||
AUTH_REPLY(8),
|
AUTH_REPLY(8),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* heartbeat type
|
||||||
|
* serverbound
|
||||||
|
*/
|
||||||
HEARTBEAT(2),
|
HEARTBEAT(2),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* server reply heartbeat packet
|
||||||
|
* clientbound
|
||||||
|
*/
|
||||||
HEARTBEAT_REPLY(3),
|
HEARTBEAT_REPLY(3),
|
||||||
Message(5);
|
|
||||||
|
/**
|
||||||
|
* danmu or event packet
|
||||||
|
* clientbound
|
||||||
|
*/
|
||||||
|
Message(5),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* custom packet type
|
||||||
|
*/
|
||||||
|
Unknown(-999);
|
||||||
|
|
||||||
public companion object {
|
public companion object {
|
||||||
private val map = PacketType.entries.associateBy { it.pkCode }
|
private val map = PacketType.entries.associateBy { it.pkCode }
|
||||||
|
|||||||
@@ -7,8 +7,25 @@
|
|||||||
|
|
||||||
package cn.rtast.bldm.codec.protocol
|
package cn.rtast.bldm.codec.protocol
|
||||||
|
|
||||||
|
/**
|
||||||
|
* danmu packet protocol version
|
||||||
|
* indicates whether the packet is compressed
|
||||||
|
*/
|
||||||
internal enum class ProtocolVersion(val proto: Short) {
|
internal enum class ProtocolVersion(val proto: Short) {
|
||||||
Raw(0), Zlib(2);
|
/**
|
||||||
|
* raw variant 0
|
||||||
|
*/
|
||||||
|
Raw0(0),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* raw variant 1
|
||||||
|
*/
|
||||||
|
Raw1(1),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* zlib compressed
|
||||||
|
*/
|
||||||
|
Zlib(2);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val map = entries.associateBy { it.proto }
|
private val map = entries.associateBy { it.proto }
|
||||||
|
|||||||
+26
-33
@@ -6,63 +6,56 @@
|
|||||||
|
|
||||||
package cn.rtast.bldm.codec.protocol.codec
|
package cn.rtast.bldm.codec.protocol.codec
|
||||||
|
|
||||||
import cn.rtast.bldm.codec.data.protocol.RawDanmuMessage
|
import cn.rtast.bldm.codec.event.DanmuEvent
|
||||||
|
import cn.rtast.bldm.codec.event.DanmuEventDispatcher
|
||||||
import cn.rtast.bldm.codec.protocol.PacketType
|
import cn.rtast.bldm.codec.protocol.PacketType
|
||||||
import cn.rtast.bldm.codec.protocol.ProtocolVersion
|
import cn.rtast.bldm.codec.protocol.ProtocolVersion
|
||||||
import cn.rtast.bldm.codec.protocol.ProtocolVersion.Raw
|
import cn.rtast.bldm.codec.protocol.ProtocolVersion.*
|
||||||
import cn.rtast.bldm.codec.protocol.ProtocolVersion.Zlib
|
|
||||||
import cn.rtast.bldm.codec.protocol.event.PacketEvents
|
|
||||||
import cn.rtast.bldm.codec.util.zlibDecompress
|
import cn.rtast.bldm.codec.util.zlibDecompress
|
||||||
import kotlinx.io.Buffer
|
import kotlinx.io.Buffer
|
||||||
import kotlinx.io.readByteArray
|
import kotlinx.io.readByteArray
|
||||||
|
|
||||||
public class PacketDecoder(eventsBuilder: PacketEvents.() -> Unit) {
|
public class PacketDecoder(private val dispatcher: DanmuEventDispatcher) {
|
||||||
private val events = PacketEvents().apply(eventsBuilder)
|
|
||||||
private val persistentBuffer = Buffer()
|
private val persistentBuffer = Buffer()
|
||||||
|
|
||||||
public fun decode(bytes: ByteArray) {
|
public suspend fun decode(bytes: ByteArray) {
|
||||||
persistentBuffer.write(bytes)
|
persistentBuffer.write(bytes)
|
||||||
this.decodeBuffer(persistentBuffer)
|
this.decodeBuffer(persistentBuffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun decodeBuffer(buffer: Buffer) {
|
/**
|
||||||
|
* process data packet, including the slicing and decoding
|
||||||
|
*/
|
||||||
|
private suspend fun decodeBuffer(buffer: Buffer) {
|
||||||
while (buffer.size >= 16) {
|
while (buffer.size >= 16) {
|
||||||
val pk = buffer.peek()
|
val pk = buffer.peek()
|
||||||
val packetLength = pk.readInt()
|
val packetLength = pk.readInt()
|
||||||
val headerLength = pk.readShort()
|
val headerLength = pk.readShort().toInt()
|
||||||
val protocolVersion = pk.readShort()
|
val protocolVersion = ProtocolVersion.fromCode(pk.readShort())
|
||||||
val opCode = pk.readInt()
|
val packetType = PacketType.fromCode(pk.readInt()) ?: PacketType.Unknown
|
||||||
|
|
||||||
if (buffer.size < packetLength) break
|
if (buffer.size < packetLength) break
|
||||||
|
buffer.skip(headerLength.toLong())
|
||||||
val currentPacket = buffer.readByteArray(packetLength)
|
|
||||||
val bodySize = packetLength - headerLength
|
val bodySize = packetLength - headerLength
|
||||||
if (bodySize <= 0) continue
|
val body = if (bodySize > 0) buffer.readByteArray(bodySize) else null
|
||||||
|
when (protocolVersion) {
|
||||||
val body = currentPacket.copyOfRange(headerLength.toInt(), packetLength)
|
Raw1, Raw0 -> handlePayload(packetType, body)
|
||||||
|
Zlib -> body?.let {
|
||||||
when (ProtocolVersion.fromCode(protocolVersion)) {
|
val decompressed = it.zlibDecompress()
|
||||||
Raw -> handlePayload(opCode, body)
|
|
||||||
Zlib -> {
|
|
||||||
val decompressed = body.zlibDecompress()
|
|
||||||
val decompressedBuffer = Buffer().apply { write(decompressed) }
|
val decompressedBuffer = Buffer().apply { write(decompressed) }
|
||||||
decodeBuffer(decompressedBuffer)
|
decodeBuffer(decompressedBuffer)
|
||||||
}
|
}
|
||||||
null -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handlePayload(opCode: Int, body: ByteArray) {
|
|
||||||
when (PacketType.fromCode(opCode)) {
|
|
||||||
PacketType.AUTH_REPLY -> events.authReplyHandler?.invoke()
|
|
||||||
PacketType.HEARTBEAT_REPLY -> events.heartbeatHandler?.invoke()
|
|
||||||
PacketType.Message -> events.messageHandler?.invoke(decodeDanmuMessage(body))
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun decodeDanmuMessage(bytes: ByteArray): RawDanmuMessage {
|
private suspend fun handlePayload(packetType: PacketType, body: ByteArray?) {
|
||||||
return RawDanmuMessage(bytes.decodeToString())
|
when (packetType) {
|
||||||
|
PacketType.AUTH_REPLY -> dispatcher.dispatch(DanmuEvent.AuthReplyEvent)
|
||||||
|
PacketType.HEARTBEAT_REPLY -> dispatcher.dispatch(DanmuEvent.HeartbeatEvent)
|
||||||
|
PacketType.Message -> body?.let { dispatcher.dispatch(DanmuEvent.MessageEvent(it.decodeToString())) }
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,16 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
@file:OptIn(InternalBldmApi::class)
|
|
||||||
|
|
||||||
package cn.rtast.bldm.codec.protocol.codec
|
package cn.rtast.bldm.codec.protocol.codec
|
||||||
|
|
||||||
import cn.rtast.bldm.codec.annotations.InternalBldmApi
|
|
||||||
import cn.rtast.bldm.codec.data.protocol.AuthPayload
|
import cn.rtast.bldm.codec.data.protocol.AuthPayload
|
||||||
import cn.rtast.bldm.codec.protocol.Packet
|
import cn.rtast.bldm.codec.protocol.Packet
|
||||||
import cn.rtast.bldm.codec.protocol.PacketType
|
import cn.rtast.bldm.codec.protocol.PacketType
|
||||||
import cn.rtast.bldm.codec.util.AutoIncrementInt
|
import cn.rtast.bldm.codec.util.AutoIncrementInt
|
||||||
import cn.rtast.bldm.codec.util._encodeJson
|
|
||||||
|
|
||||||
public class PacketEncoder {
|
public class PacketEncoder {
|
||||||
public companion object {
|
public companion object {
|
||||||
@@ -24,10 +20,13 @@ public class PacketEncoder {
|
|||||||
private val _sequence by AutoIncrementInt()
|
private val _sequence by AutoIncrementInt()
|
||||||
|
|
||||||
public fun authPacket(uid: Long, roomId: Long, buvid: String, token: String): Packet {
|
public fun authPacket(uid: Long, roomId: Long, buvid: String, token: String): Packet {
|
||||||
val payload = AuthPayload(uid, roomId, 2, buvid, "web", 2, token)
|
val payload = AuthPayload(uid, roomId, 2, buvid, "web", 2, token).toString().encodeToByteArray()
|
||||||
._encodeJson().encodeToByteArray()
|
|
||||||
return Packet(packetType = PacketType.AUTH.pkCode, sequence = _sequence, body = payload)
|
return Packet(packetType = PacketType.AUTH.pkCode, sequence = _sequence, body = payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
public fun heartbeatPacket(): Packet = Packet(packetType = 2, sequence = _sequence, body = HEARTBEAT_BODY)
|
public fun heartbeatPacket(): Packet = Packet(
|
||||||
|
packetType = PacketType.HEARTBEAT.pkCode,
|
||||||
|
sequence = _sequence,
|
||||||
|
body = HEARTBEAT_BODY
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright © 2026 RTAkland
|
|
||||||
* Author: RTAkland
|
|
||||||
* Date: 2026/8/30
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
package cn.rtast.bldm.codec.protocol.event
|
|
||||||
|
|
||||||
import cn.rtast.bldm.codec.data.protocol.RawDanmuMessage
|
|
||||||
|
|
||||||
public class PacketEvents {
|
|
||||||
internal var messageHandler: ((RawDanmuMessage) -> Unit)? = null
|
|
||||||
internal var heartbeatHandler: (() -> Unit)? = null
|
|
||||||
internal var authReplyHandler: (() -> Unit)? = null
|
|
||||||
|
|
||||||
public fun onMessage(block: (RawDanmuMessage) -> Unit) {
|
|
||||||
messageHandler = block
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun onHeartbeat(block: () -> Unit) {
|
|
||||||
heartbeatHandler = block
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun onAuthReply(block: () -> Unit) {
|
|
||||||
authReplyHandler = block
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,252 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright © 2026 RTAkland
|
* Copyright © 2026 RTAkland
|
||||||
* Author: RTAkland
|
* Author: RTAkland
|
||||||
* Date: 2026/8/30
|
* Date: 2026/8/31
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
package cn.rtast.bldm.codec.util
|
package cn.rtast.bldm.codec.util
|
||||||
|
|
||||||
import org.kotlincrypto.hash.md.MD5
|
|
||||||
|
|
||||||
internal val md5Instance = MD5()
|
internal object CycloneMd5 {
|
||||||
|
const val BLOCK_SIZE = 64
|
||||||
|
const val DIGEST_SIZE = 16
|
||||||
|
const val MIN_PAD_SIZE = 9
|
||||||
|
|
||||||
internal fun String.digest(): String = md5Instance.digest(this.encodeToByteArray()).toHexString()
|
val OID = byteArrayOf(
|
||||||
|
0x2A.toByte(), 0x86.toByte(), 0x48.toByte(), 0x86.toByte(),
|
||||||
|
0xF7.toByte(), 0x0D.toByte(), 0x02.toByte(), 0x05.toByte()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val PADDING = ByteArray(64).apply { this[0] = 0x80.toByte() }
|
||||||
|
|
||||||
|
private val K = intArrayOf(
|
||||||
|
0xD76AA478.toInt(), 0xE8C7B756.toInt(), 0x242070DB, 0xC1BDCEEE.toInt(),
|
||||||
|
0xF57C0FAF.toInt(), 0x4787C62A, 0xA8304613.toInt(), 0xFD469501.toInt(),
|
||||||
|
0x698098D8, 0x8B44F7AF.toInt(), 0xFFFF5BB1.toInt(), 0x895CD7BE.toInt(),
|
||||||
|
0x6B901122, 0xFD987193.toInt(), 0xA679438E.toInt(), 0x49B40821,
|
||||||
|
0xF61E2562.toInt(), 0xC040B340.toInt(), 0x265E5A51, 0xE9B6C7AA.toInt(),
|
||||||
|
0xD62F105D.toInt(), 0x02441453, 0xD8A1E681.toInt(), 0xE7D3FBC8.toInt(),
|
||||||
|
0x21E1CDE6, 0xC33707D6.toInt(), 0xF4D50D87.toInt(), 0x455A14ED,
|
||||||
|
0xA9E3E905.toInt(), 0xFCEFA3F8.toInt(), 0x676F02D9, 0x8D2A4C8A.toInt(),
|
||||||
|
0xFFFA3942.toInt(), 0x8771F681.toInt(), 0x6D9D6122, 0xFDE5380C.toInt(),
|
||||||
|
0xA4BEEA44.toInt(), 0x4BDECFA9, 0xF6BB4B60.toInt(), 0xBEBFBC70.toInt(),
|
||||||
|
0x289B7EC6, 0xEAA127FA.toInt(), 0xD4EF3085.toInt(), 0x04881D05,
|
||||||
|
0xD9D4D039.toInt(), 0xE6DB99E5.toInt(), 0x1FA27CF8, 0xC4AC5665.toInt(),
|
||||||
|
0xF4292244.toInt(), 0x432AFF97, 0xAB9423A7.toInt(), 0xFC93A039.toInt(),
|
||||||
|
0x655B59C3, 0x8F0CCC92.toInt(), 0xFFEFF47D.toInt(), 0x85845DD1.toInt(),
|
||||||
|
0x6FA87E4F, 0xFE2CE6E0.toInt(), 0xA3014314.toInt(), 0x4E0811A1,
|
||||||
|
0xF7537E82.toInt(), 0xBD3AF235.toInt(), 0x2AD7D2BB, 0xEB86D391.toInt()
|
||||||
|
)
|
||||||
|
|
||||||
|
private class Context {
|
||||||
|
val h = IntArray(4)
|
||||||
|
val buffer = ByteArray(64)
|
||||||
|
val x = IntArray(16)
|
||||||
|
var size: Int = 0
|
||||||
|
var totalSize: Long = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
fun compute(data: ByteArray): ByteArray {
|
||||||
|
val digest = ByteArray(DIGEST_SIZE)
|
||||||
|
val context = Context()
|
||||||
|
initContext(context)
|
||||||
|
updateContext(context, data, 0, data.size)
|
||||||
|
finalContext(context, digest)
|
||||||
|
return digest
|
||||||
|
}
|
||||||
|
|
||||||
|
fun computeToHex(data: ByteArray): String {
|
||||||
|
return compute(data).toHexString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun computeToHex(text: String): String {
|
||||||
|
return compute(text.encodeToByteArray()).toHexString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initContext(context: Context) {
|
||||||
|
context.h[0] = 0x67452301
|
||||||
|
context.h[1] = 0xEFCDAB89.toInt()
|
||||||
|
context.h[2] = 0x98BADCFE.toInt()
|
||||||
|
context.h[3] = 0x10325476
|
||||||
|
context.size = 0
|
||||||
|
context.totalSize = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateContext(context: Context, data: ByteArray, offset: Int, length: Int) {
|
||||||
|
var dataOffset = offset
|
||||||
|
var remLength = length
|
||||||
|
|
||||||
|
while (remLength > 0) {
|
||||||
|
val n = minOf(remLength, 64 - context.size)
|
||||||
|
data.copyInto(context.buffer, context.size, dataOffset, dataOffset + n)
|
||||||
|
|
||||||
|
context.size += n
|
||||||
|
context.totalSize += n
|
||||||
|
dataOffset += n
|
||||||
|
remLength -= n
|
||||||
|
|
||||||
|
if (context.size == 64) {
|
||||||
|
processBlock(context)
|
||||||
|
context.size = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finalContext(context: Context, digest: ByteArray) {
|
||||||
|
var totalBits: Long = context.totalSize * 8L
|
||||||
|
val paddingSize = if (context.size < 56) {
|
||||||
|
56 - context.size
|
||||||
|
} else {
|
||||||
|
64 + 56 - context.size
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContext(context, PADDING, 0, paddingSize)
|
||||||
|
|
||||||
|
for (i in 0 until 8) {
|
||||||
|
context.buffer[56 + i] = (totalBits and 0xFFL).toByte()
|
||||||
|
totalBits = totalBits ushr 8
|
||||||
|
}
|
||||||
|
|
||||||
|
processBlock(context)
|
||||||
|
|
||||||
|
for (i in 0 until (DIGEST_SIZE / 4)) {
|
||||||
|
store32le(context.h[i], digest, i * 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processBlock(context: Context) {
|
||||||
|
var a = context.h[0]
|
||||||
|
var b = context.h[1]
|
||||||
|
var c = context.h[2]
|
||||||
|
var d = context.h[3]
|
||||||
|
|
||||||
|
val x = context.x
|
||||||
|
|
||||||
|
for (i in 0 until 16) {
|
||||||
|
x[i] = load32le(context.buffer, i * 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round 1
|
||||||
|
a = ff(a, b, c, d, x[0], 7, K[0])
|
||||||
|
d = ff(d, a, b, c, x[1], 12, K[1])
|
||||||
|
c = ff(c, d, a, b, x[2], 17, K[2])
|
||||||
|
b = ff(b, c, d, a, x[3], 22, K[3])
|
||||||
|
a = ff(a, b, c, d, x[4], 7, K[4])
|
||||||
|
d = ff(d, a, b, c, x[5], 12, K[5])
|
||||||
|
c = ff(c, d, a, b, x[6], 17, K[6])
|
||||||
|
b = ff(b, c, d, a, x[7], 22, K[7])
|
||||||
|
a = ff(a, b, c, d, x[8], 7, K[8])
|
||||||
|
d = ff(d, a, b, c, x[9], 12, K[9])
|
||||||
|
c = ff(c, d, a, b, x[10], 17, K[10])
|
||||||
|
b = ff(b, c, d, a, x[11], 22, K[11])
|
||||||
|
a = ff(a, b, c, d, x[12], 7, K[12])
|
||||||
|
d = ff(d, a, b, c, x[13], 12, K[13])
|
||||||
|
c = ff(c, d, a, b, x[14], 17, K[14])
|
||||||
|
b = ff(b, c, d, a, x[15], 22, K[15])
|
||||||
|
|
||||||
|
// Round 2
|
||||||
|
a = gg(a, b, c, d, x[1], 5, K[16])
|
||||||
|
d = gg(d, a, b, c, x[6], 9, K[17])
|
||||||
|
c = gg(c, d, a, b, x[11], 14, K[18])
|
||||||
|
b = gg(b, c, d, a, x[0], 20, K[19])
|
||||||
|
a = gg(a, b, c, d, x[5], 5, K[20])
|
||||||
|
d = gg(d, a, b, c, x[10], 9, K[21])
|
||||||
|
c = gg(c, d, a, b, x[15], 14, K[22])
|
||||||
|
b = gg(b, c, d, a, x[4], 20, K[23])
|
||||||
|
a = gg(a, b, c, d, x[9], 5, K[24])
|
||||||
|
d = gg(d, a, b, c, x[14], 9, K[25])
|
||||||
|
c = gg(c, d, a, b, x[3], 14, K[26])
|
||||||
|
b = gg(b, c, d, a, x[8], 20, K[27])
|
||||||
|
a = gg(a, b, c, d, x[13], 5, K[28])
|
||||||
|
d = gg(d, a, b, c, x[2], 9, K[29])
|
||||||
|
c = gg(c, d, a, b, x[7], 14, K[30])
|
||||||
|
b = gg(b, c, d, a, x[12], 20, K[31])
|
||||||
|
|
||||||
|
// Round 3
|
||||||
|
a = hh(a, b, c, d, x[5], 4, K[32])
|
||||||
|
d = hh(d, a, b, c, x[8], 11, K[33])
|
||||||
|
c = hh(c, d, a, b, x[11], 16, K[34])
|
||||||
|
b = hh(b, c, d, a, x[14], 23, K[35])
|
||||||
|
a = hh(a, b, c, d, x[1], 4, K[36])
|
||||||
|
d = hh(d, a, b, c, x[4], 11, K[37])
|
||||||
|
c = hh(c, d, a, b, x[7], 16, K[38])
|
||||||
|
b = hh(b, c, d, a, x[10], 23, K[39])
|
||||||
|
a = hh(a, b, c, d, x[13], 4, K[40])
|
||||||
|
d = hh(d, a, b, c, x[0], 11, K[41])
|
||||||
|
c = hh(c, d, a, b, x[3], 16, K[42])
|
||||||
|
b = hh(b, c, d, a, x[6], 23, K[43])
|
||||||
|
a = hh(a, b, c, d, x[9], 4, K[44])
|
||||||
|
d = hh(d, a, b, c, x[12], 11, K[45])
|
||||||
|
c = hh(c, d, a, b, x[15], 16, K[46])
|
||||||
|
b = hh(b, c, d, a, x[2], 23, K[47])
|
||||||
|
|
||||||
|
// Round 4
|
||||||
|
a = ii(a, b, c, d, x[0], 6, K[48])
|
||||||
|
d = ii(d, a, b, c, x[7], 10, K[49])
|
||||||
|
c = ii(c, d, a, b, x[14], 15, K[50])
|
||||||
|
b = ii(b, c, d, a, x[5], 21, K[51])
|
||||||
|
a = ii(a, b, c, d, x[12], 6, K[52])
|
||||||
|
d = ii(d, a, b, c, x[3], 10, K[53])
|
||||||
|
c = ii(c, d, a, b, x[10], 15, K[54])
|
||||||
|
b = ii(b, c, d, a, x[1], 21, K[55])
|
||||||
|
a = ii(a, b, c, d, x[8], 6, K[56])
|
||||||
|
d = ii(d, a, b, c, x[15], 10, K[57])
|
||||||
|
c = ii(c, d, a, b, x[6], 15, K[58])
|
||||||
|
b = ii(b, c, d, a, x[13], 21, K[59])
|
||||||
|
a = ii(a, b, c, d, x[4], 6, K[60])
|
||||||
|
d = ii(d, a, b, c, x[11], 10, K[61])
|
||||||
|
c = ii(c, d, a, b, x[2], 15, K[62])
|
||||||
|
b = ii(b, c, d, a, x[9], 21, K[63])
|
||||||
|
|
||||||
|
context.h[0] += a
|
||||||
|
context.h[1] += b
|
||||||
|
context.h[2] += c
|
||||||
|
context.h[3] += d
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.toHexString(): String {
|
||||||
|
val hexChars = CharArray(size * 2)
|
||||||
|
val hexArray = "0123456789abcdef".toCharArray()
|
||||||
|
for (i in indices) {
|
||||||
|
val v = this[i].toInt() and 0xFF
|
||||||
|
hexChars[i * 2] = hexArray[v ushr 4]
|
||||||
|
hexChars[i * 2 + 1] = hexArray[v and 0x0F]
|
||||||
|
}
|
||||||
|
return hexChars.concatToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rol32(a: Int, s: Int): Int = (a shl s) or (a ushr (32 - s))
|
||||||
|
|
||||||
|
private fun load32le(buf: ByteArray, offset: Int): Int {
|
||||||
|
return (buf[offset].toInt() and 0xFF) or
|
||||||
|
((buf[offset + 1].toInt() and 0xFF) shl 8) or
|
||||||
|
((buf[offset + 2].toInt() and 0xFF) shl 16) or
|
||||||
|
((buf[offset + 3].toInt() and 0xFF) shl 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun store32le(val32: Int, buf: ByteArray, offset: Int) {
|
||||||
|
buf[offset] = (val32 and 0xFF).toByte()
|
||||||
|
buf[offset + 1] = ((val32 ushr 8) and 0xFF).toByte()
|
||||||
|
buf[offset + 2] = ((val32 ushr 16) and 0xFF).toByte()
|
||||||
|
buf[offset + 3] = ((val32 ushr 24) and 0xFF).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun f(x: Int, y: Int, z: Int): Int = (x and y) or (x.inv() and z)
|
||||||
|
private fun g(x: Int, y: Int, z: Int): Int = (x and z) or (y and z.inv())
|
||||||
|
private fun h(x: Int, y: Int, z: Int): Int = x xor y xor z
|
||||||
|
private fun i(x: Int, y: Int, z: Int): Int = y xor (x or z.inv())
|
||||||
|
|
||||||
|
private fun ff(a: Int, b: Int, c: Int, d: Int, x: Int, s: Int, k: Int): Int =
|
||||||
|
rol32(a + f(b, c, d) + x + k, s) + b
|
||||||
|
|
||||||
|
private fun gg(a: Int, b: Int, c: Int, d: Int, x: Int, s: Int, k: Int): Int =
|
||||||
|
rol32(a + g(b, c, d) + x + k, s) + b
|
||||||
|
|
||||||
|
private fun hh(a: Int, b: Int, c: Int, d: Int, x: Int, s: Int, k: Int): Int =
|
||||||
|
rol32(a + h(b, c, d) + x + k, s) + b
|
||||||
|
|
||||||
|
private fun ii(a: Int, b: Int, c: Int, d: Int, x: Int, s: Int, k: Int): Int =
|
||||||
|
rol32(a + i(b, c, d) + x + k, s) + b
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String.digest(): String = CycloneMd5.computeToHex(this)
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright © 2026 RTAkland
|
|
||||||
* Author: RTAkland
|
|
||||||
* Date: 2026/8/30
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:Suppress("FunctionName")
|
|
||||||
|
|
||||||
package cn.rtast.bldm.codec.util
|
|
||||||
|
|
||||||
import cn.rtast.bldm.codec.annotations.InternalBldmApi
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
|
|
||||||
internal val dmMessageParser = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
classDiscriminator = "cmd"
|
|
||||||
}
|
|
||||||
|
|
||||||
@InternalBldmApi
|
|
||||||
@Suppress("ObjectPropertyName")
|
|
||||||
public val _json: Json = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
isLenient = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@InternalBldmApi
|
|
||||||
public inline fun <reified T> String._fromJson(): T = _json.decodeFromString(this)
|
|
||||||
|
|
||||||
@InternalBldmApi
|
|
||||||
public inline fun <reified T> T._encodeJson(): String = _json.encodeToString(this)
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* Copyright © 2026 RTAkland
|
||||||
|
* Author: RTAkland
|
||||||
|
* Date: 2026/8/31
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
package cn.rtast.bldm.codec.util
|
||||||
|
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
private val MIXIN_KEY_ENC_TAB = intArrayOf(
|
||||||
|
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
|
||||||
|
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
|
||||||
|
61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
|
||||||
|
36, 20, 34, 44, 52
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun String.encodeURIComponent(): String {
|
||||||
|
val bytes = this.encodeToByteArray()
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (b in bytes) {
|
||||||
|
val c = b.toInt().and(0xFF).toChar()
|
||||||
|
if (c in 'A'..'Z' || c in 'a'..'z' || c in '0'..'9' || c == '-' || c == '_' || c == '.' || c == '~') {
|
||||||
|
sb.append(c)
|
||||||
|
} else {
|
||||||
|
val hex = (b.toInt() and 0xFF).toString(16).uppercase()
|
||||||
|
sb.append('%')
|
||||||
|
if (hex.length == 1) sb.append('0')
|
||||||
|
sb.append(hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Map<String, Any?>.toQueryString(): String {
|
||||||
|
return this.mapNotNull { (key, value) ->
|
||||||
|
if (value != null) "${key.encodeURIComponent()}=${value.toString().encodeURIComponent()}" else null
|
||||||
|
}.joinToString("&")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMixinKey(imgUrl: String, subUrl: String): String =
|
||||||
|
(imgUrl.substringAfterLast('/').removeSuffix(".png") + subUrl.substringAfterLast('/')
|
||||||
|
.removeSuffix(".png")).let { s ->
|
||||||
|
buildString { repeat(32) { append(s[MIXIN_KEY_ENC_TAB[it]]) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun signWbi(room: Long, imgUrl: String, subUrl: String): String {
|
||||||
|
val wts = Clock.System.now().epochSeconds
|
||||||
|
val params = mapOf("id" to room, "type" to 0)
|
||||||
|
.entries.sortedBy { it.key }.associate { it.key to it.value }.toMutableMap()
|
||||||
|
return buildString {
|
||||||
|
append(params.toQueryString())
|
||||||
|
params["wts"] = wts
|
||||||
|
append("&wts=$wts")
|
||||||
|
append("&w_rid=${(params.toQueryString() + getMixinKey(imgUrl, subUrl)).digest()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,18 +7,17 @@
|
|||||||
|
|
||||||
package test
|
package test
|
||||||
|
|
||||||
import org.kotlincrypto.hash.md.MD5
|
import cn.rtast.bldm.codec.util.digest
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
class TestMd5 {
|
class TestMd5 {
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `test md5`() {
|
|
||||||
val target = "e10adc3949ba59abbe56e057f20f883e" // 123456
|
val target = "e10adc3949ba59abbe56e057f20f883e" // 123456
|
||||||
|
|
||||||
val res = MD5().digest("123456".encodeToByteArray()).toHexString()
|
@Test
|
||||||
|
fun `test self md5`() {
|
||||||
assertEquals(target, res)
|
val output = "123456".digest()
|
||||||
|
assertEquals(target, output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
api(project(":bl-codec"))
|
api(project(":bl-model"))
|
||||||
api("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.11.0")
|
api("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.11.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long.
@@ -0,0 +1,28 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
kotlin("multiplatform")
|
||||||
|
kotlin("plugin.serialization")
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
explicitApi()
|
||||||
|
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
macosArm64()
|
||||||
|
mingwX64()
|
||||||
|
jvm { compilerOptions.jvmTarget = JvmTarget.JVM_11 }
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(project(":bl-codec"))
|
||||||
|
api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-4
@@ -1,13 +1,14 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright © 2026 RTAkland
|
* Copyright © 2026 RTAkland
|
||||||
* Author: RTAkland
|
* Author: RTAkland
|
||||||
* Date: 2026/8/29
|
* Date: 2026/8/31
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package cn.rtast.bldm.codec
|
|
||||||
|
|
||||||
public object BLDMConstants {
|
package cn.rtast.bldm.dto
|
||||||
public const val REAL_ROOM_ID_URL: String = "https://api.live.bilibili.com/room/v1/Room/room_init?id="
|
|
||||||
|
public object BldmConstant {
|
||||||
|
public const val REAL_ROOM_ID_URL: String = "https://api.live.bilibili.com/room/v1/Room/room_init"
|
||||||
public const val USER_NAV_URL: String = "https://api.bilibili.com/x/web-interface/nav"
|
public const val USER_NAV_URL: String = "https://api.bilibili.com/x/web-interface/nav"
|
||||||
public const val DM_SERVER_CONF_URL: String =
|
public const val DM_SERVER_CONF_URL: String =
|
||||||
"https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo"
|
"https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo"
|
||||||
+8
-2
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
package cn.rtast.bldm.codec.data
|
package cn.rtast.bldm.dto
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
@@ -30,7 +30,13 @@ public data class DMServerConf(
|
|||||||
val wssPort: Int,
|
val wssPort: Int,
|
||||||
@SerialName("ws_port")
|
@SerialName("ws_port")
|
||||||
val wsPort: Int,
|
val wsPort: Int,
|
||||||
)
|
) {
|
||||||
|
@Transient
|
||||||
|
public val tlsWsAddress: String = "wss://$host:$wssPort/sub"
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
public val wsAddress: String = "ws://$host:$wsPort/sub"
|
||||||
|
}
|
||||||
|
|
||||||
@Transient
|
@Transient
|
||||||
public val defaultServerHost: ServerHost = ServerHost("broadcastlv.chat.bilibili.com", 2243, 2245, 2244)
|
public val defaultServerHost: ServerHost = ServerHost("broadcastlv.chat.bilibili.com", 2243, 2245, 2244)
|
||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
package cn.rtast.bldm.codec.data
|
package cn.rtast.bldm.dto
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
* Copyright © 2026 RTAkland
|
||||||
|
* Author: RTAkland
|
||||||
|
* Date: 2026/8/30
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
package cn.rtast.bldm.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
public data class UserNavData(
|
||||||
|
val data: NavData,
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
public data class NavData(
|
||||||
|
@SerialName("wbi_img")
|
||||||
|
val wbiImg: WbiImg,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
public data class WbiImg(
|
||||||
|
@SerialName("img_url")
|
||||||
|
val imgUrl: String,
|
||||||
|
@SerialName("sub_url")
|
||||||
|
val subUrl: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
-1
@@ -2,4 +2,4 @@ kotlin.code.style=official
|
|||||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
kotlin.native.ignoreDisabledTargets=true
|
kotlin.native.ignoreDisabledTargets=true
|
||||||
|
|
||||||
libVersion=0.1-snapshots
|
libVersion=0.1.0
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
rootProject.name = "bldm"
|
rootProject.name = "bldm"
|
||||||
|
|
||||||
include(":bl-codec")
|
include(":bl-codec")
|
||||||
|
include(":bl-model")
|
||||||
include(":bl-client")
|
include(":bl-client")
|
||||||
include(":bl-dm-codec")
|
include(":bl-dm-codec")
|
||||||
Reference in New Issue
Block a user