Introduce event dispatcher and fix heartbeat, auth reply events

This commit is contained in:
2026-08-31 15:43:44 +08:00
parent 0e09422b6e
commit fe49e4ff63
34 files changed
+686 -345

No files matched your search

-1
View File
@@ -45,4 +45,3 @@ bin/
.DS_Store
/.idea/
/bl-client/src/commonTest/resources/cookie.txt
/bl-client/src/commonTest/resources/res.txt
+6
View File
@@ -18,10 +18,16 @@ Supported platforms:
[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
[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
Licensed under [Apache-2.0](LICENSE)
+5 -1
View File
@@ -17,11 +17,15 @@ kotlin {
sourceSets {
val ktorVersion = "3.5.2"
commonMain.dependencies {
api(project(":bl-codec"))
api(project(":bl-model"))
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-websockets:$ktorVersion")
}
jvmMain.dependencies {
implementation("io.ktor:ktor-client-java:${ktorVersion}")
}
appleMain.dependencies {
implementation("io.ktor:ktor-client-darwin:${ktorVersion}")
}
@@ -4,55 +4,54 @@
* Date: 2026/8/30
*/
@file:OptIn(InternalBldmApi::class)
package cn.rtast.bldm.client
import cn.rtast.bldm.codec.BLDMConstants
import cn.rtast.bldm.codec.annotations.InternalBldmApi
import cn.rtast.bldm.codec.data.DMServerConf
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.client.util.fromJson
import cn.rtast.bldm.client.util.sendPacket
import cn.rtast.bldm.codec.event.DanmuEventDispatcher
import cn.rtast.bldm.codec.protocol.codec.PacketDecoder
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.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.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.websocket.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.seconds
private val HEARTBEAT_INTERVAL = 30.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
}
public suspend fun connectToBlDM(
roomId: Long,
cookie: String?,
events: PacketEvents.() -> Unit,
) {
private val packetEncoder = PacketEncoder()
private val decoder = PacketDecoder(this)
private val clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var connectionJob: Job? = null
private var session: DefaultClientWebSocketSession? = null
private suspend fun internalConnect() {
val parsedCookie = parseBuvidAndUid(cookie ?: "")
val packetEncoder = PacketEncoder()
val packetDecoder = PacketDecoder(events)
val realRoomId = httpClient.get(BLDMConstants.REAL_ROOM_ID_URL + roomId)
.bodyAsText()._fromJson<RealRoomId>()
val wbi = httpClient.get(BLDMConstants.USER_NAV_URL)
.bodyAsText()._fromJson<UserNavData>().signWbi(realRoomId.data.roomId)
val serverConf = httpClient.get(BLDMConstants.DM_SERVER_CONF_URL + "?$wbi") {
val realRoomId = httpClient.get(BldmConstant.REAL_ROOM_ID_URL + "?id=$roomId")
.bodyAsText().fromJson<RealRoomId>()
val navData = httpClient.get(BldmConstant.USER_NAV_URL)
.bodyAsText().fromJson<UserNavData>().data.wbiImg
val wbi = signWbi(realRoomId.data.roomId, navData.imgUrl, navData.subUrl)
val serverConf = httpClient.get(BldmConstant.DM_SERVER_CONF_URL + "?$wbi") {
cookie?.let { header(HttpHeaders.Cookie, it) }
}.bodyAsText()._fromJson<DMServerConf>().data
val wsUrl = "ws://${serverConf.hostList.first().host}:${serverConf.hostList.first().wsPort}/sub"
}.bodyAsText().fromJson<DMServerConf>().data
httpClient.webSocket(wsUrl, {
// header(HttpHeaders.Host, "${serverConf.hostList.first().host}:${serverConf.hostList.first().wssPort}")
// header(HttpHeaders.Origin, "https://www.bilibili.com")
}) {
httpClient.webSocket(serverConf.hostList.first().tlsWsAddress) {
session = this
val authPacket = packetEncoder.authPacket(
parsedCookie.second ?: 0,
realRoomId.data.roomId,
@@ -67,25 +66,46 @@ public suspend fun connectToBlDM(
runCatching {
sendPacket(heartbeatPacket)
}.onFailure {
println("heartbeat failed ${it.message}")
}
}
}
try {
for (frame in incoming) {
if (frame !is Frame.Binary) continue
val data = frame.readBytes()
packetDecoder.decode(data)
decoder.decode(data)
}
} catch (e: Exception) {
e.printStackTrace()
if (session?.isActive == true) e.printStackTrace()
} finally {
heartbeatJob.cancel()
println("Closed")
session = null
}
}
httpClient.close()
}
public fun connect() {
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()
}
}
internal suspend fun DefaultWebSocketSession.sendPacket(packet: Packet) =
send(Frame.Binary(true, packet.toByteArray()))
@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
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.io.buffered
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.readString
import kotlin.test.Test
import kotlin.time.Duration.Companion.seconds
class TestClient {
@@ -22,18 +25,15 @@ class TestClient {
@Test
fun `test bldm client`() = runTest {
connectToBlDM(6, cookie) {
onHeartbeat {
println("Heartbeat")
}
val client = BLDMClient(7777, cookie)
client.on<DanmuEvent.HeartbeatEvent> { println("Heartbeat") }
client.on<DanmuEvent.AuthReplyEvent> { println("AuthReply") }
client.on<DanmuEvent.MessageEvent> { println(it.content) }
client.connectBlocking()
onMessage {
println(it.content)
}
onAuthReply {
println("auth reply")
}
}
// remove lines below if you use client.connectBlocking()
// while (true) {
// delay(1.seconds)
// }
}
}
+1 -3
View File
@@ -16,9 +16,7 @@ kotlin {
sourceSets {
commonMain.dependencies {
api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
api("org.kotlincrypto.hash:md:0.8.0")
api("org.jetbrains.kotlinx:kotlinx-io-core:0.9.1")
implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.9.1")
}
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
import kotlinx.serialization.Serializable
@Serializable
internal data class AuthPayload(
/**
* user id
@@ -18,12 +15,12 @@ internal data class AuthPayload(
/**
* room id
*/
val roomid: Long,
val roomId: Long,
/**
* protocol version
* always be 2
*/
val protover: Int,
val protocolVersion: Int,
/**
* device id
*/
@@ -39,5 +36,24 @@ internal data class AuthPayload(
/**
* 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
}
/**
* Encoding to [ByteArray]
*/
public fun toByteArray(): ByteArray = Buffer().apply {
writeInt(16 + body.size)
writeShort(headerLength)
@@ -7,12 +7,45 @@
package cn.rtast.bldm.codec.protocol
/**
* packet layer type
* also represents opcode
*/
public enum class PacketType(public val pkCode: Int) {
/**
* auth packet type
* serverbound
*/
AUTH(7),
/**
* server reply auth packet
* clientbound
*/
AUTH_REPLY(8),
/**
* heartbeat type
* serverbound
*/
HEARTBEAT(2),
/**
* server reply heartbeat packet
* clientbound
*/
HEARTBEAT_REPLY(3),
Message(5);
/**
* danmu or event packet
* clientbound
*/
Message(5),
/**
* custom packet type
*/
Unknown(-999);
public companion object {
private val map = PacketType.entries.associateBy { it.pkCode }
@@ -7,8 +7,25 @@
package cn.rtast.bldm.codec.protocol
/**
* danmu packet protocol version
* indicates whether the packet is compressed
*/
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 {
private val map = entries.associateBy { it.proto }
@@ -6,63 +6,56 @@
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.ProtocolVersion
import cn.rtast.bldm.codec.protocol.ProtocolVersion.Raw
import cn.rtast.bldm.codec.protocol.ProtocolVersion.Zlib
import cn.rtast.bldm.codec.protocol.event.PacketEvents
import cn.rtast.bldm.codec.protocol.ProtocolVersion.*
import cn.rtast.bldm.codec.util.zlibDecompress
import kotlinx.io.Buffer
import kotlinx.io.readByteArray
public class PacketDecoder(eventsBuilder: PacketEvents.() -> Unit) {
private val events = PacketEvents().apply(eventsBuilder)
public class PacketDecoder(private val dispatcher: DanmuEventDispatcher) {
private val persistentBuffer = Buffer()
public fun decode(bytes: ByteArray) {
public suspend fun decode(bytes: ByteArray) {
persistentBuffer.write(bytes)
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) {
val pk = buffer.peek()
val packetLength = pk.readInt()
val headerLength = pk.readShort()
val protocolVersion = pk.readShort()
val opCode = pk.readInt()
val headerLength = pk.readShort().toInt()
val protocolVersion = ProtocolVersion.fromCode(pk.readShort())
val packetType = PacketType.fromCode(pk.readInt()) ?: PacketType.Unknown
if (buffer.size < packetLength) break
val currentPacket = buffer.readByteArray(packetLength)
buffer.skip(headerLength.toLong())
val bodySize = packetLength - headerLength
if (bodySize <= 0) continue
val body = currentPacket.copyOfRange(headerLength.toInt(), packetLength)
when (ProtocolVersion.fromCode(protocolVersion)) {
Raw -> handlePayload(opCode, body)
Zlib -> {
val decompressed = body.zlibDecompress()
val body = if (bodySize > 0) buffer.readByteArray(bodySize) else null
when (protocolVersion) {
Raw1, Raw0 -> handlePayload(packetType, body)
Zlib -> body?.let {
val decompressed = it.zlibDecompress()
val decompressedBuffer = Buffer().apply { write(decompressed) }
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 -> {}
}
}
}
private fun decodeDanmuMessage(bytes: ByteArray): RawDanmuMessage {
return RawDanmuMessage(bytes.decodeToString())
private suspend fun handlePayload(packetType: PacketType, body: ByteArray?) {
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
import cn.rtast.bldm.codec.annotations.InternalBldmApi
import cn.rtast.bldm.codec.data.protocol.AuthPayload
import cn.rtast.bldm.codec.protocol.Packet
import cn.rtast.bldm.codec.protocol.PacketType
import cn.rtast.bldm.codec.util.AutoIncrementInt
import cn.rtast.bldm.codec.util._encodeJson
public class PacketEncoder {
public companion object {
@@ -24,10 +20,13 @@ public class PacketEncoder {
private val _sequence by AutoIncrementInt()
public fun authPacket(uid: Long, roomId: Long, buvid: String, token: String): Packet {
val payload = AuthPayload(uid, roomId, 2, buvid, "web", 2, token)
._encodeJson().encodeToByteArray()
val payload = AuthPayload(uid, roomId, 2, buvid, "web", 2, token).toString().encodeToByteArray()
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
* Author: RTAkland
* Date: 2026/8/30
* Date: 2026/8/31
*/
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
import org.kotlincrypto.hash.md.MD5
import cn.rtast.bldm.codec.util.digest
import kotlin.test.Test
import kotlin.test.assertEquals
class TestMd5 {
@Test
fun `test md5`() {
val target = "e10adc3949ba59abbe56e057f20f883e" // 123456
val res = MD5().digest("123456".encodeToByteArray()).toHexString()
assertEquals(target, res)
@Test
fun `test self md5`() {
val output = "123456".digest()
assertEquals(target, output)
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ kotlin {
sourceSets {
commonMain.dependencies {
api(project(":bl-codec"))
api(project(":bl-model"))
api("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.11.0")
}
File diff suppressed because one or more lines are too long.
+28
View File
@@ -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")
}
}
}
@@ -1,13 +1,14 @@
/*
* Copyright © 2026 RTAkland
* Author: RTAkland
* Date: 2026/8/29
* Date: 2026/8/31
*/
package cn.rtast.bldm.codec
public object BLDMConstants {
public const val REAL_ROOM_ID_URL: String = "https://api.live.bilibili.com/room/v1/Room/room_init?id="
package cn.rtast.bldm.dto
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 DM_SERVER_CONF_URL: String =
"https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo"
@@ -5,7 +5,7 @@
*/
package cn.rtast.bldm.codec.data
package cn.rtast.bldm.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -30,7 +30,13 @@ public data class DMServerConf(
val wssPort: Int,
@SerialName("ws_port")
val wsPort: Int,
)
) {
@Transient
public val tlsWsAddress: String = "wss://$host:$wssPort/sub"
@Transient
public val wsAddress: String = "ws://$host:$wsPort/sub"
}
@Transient
public val defaultServerHost: ServerHost = ServerHost("broadcastlv.chat.bilibili.com", 2243, 2245, 2244)
@@ -5,7 +5,7 @@
*/
package cn.rtast.bldm.codec.data
package cn.rtast.bldm.dto
import kotlinx.serialization.SerialName
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
View File
@@ -2,4 +2,4 @@ kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError
kotlin.native.ignoreDisabledTargets=true
libVersion=0.1-snapshots
libVersion=0.1.0
+1
View File
@@ -1,5 +1,6 @@
rootProject.name = "bldm"
include(":bl-codec")
include(":bl-model")
include(":bl-client")
include(":bl-dm-codec")