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 .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
+6
View File
@@ -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)
+5 -1
View File
@@ -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,88 +4,108 @@
* 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
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( 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
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") {
cookie?.let { header(HttpHeaders.Cookie, it) }
}.bodyAsText()._fromJson<DMServerConf>().data
val wsUrl = "ws://${serverConf.hostList.first().host}:${serverConf.hostList.first().wsPort}/sub"
httpClient.webSocket(wsUrl, { private suspend fun internalConnect() {
// header(HttpHeaders.Host, "${serverConf.hostList.first().host}:${serverConf.hostList.first().wssPort}") val parsedCookie = parseBuvidAndUid(cookie ?: "")
// header(HttpHeaders.Origin, "https://www.bilibili.com") val realRoomId = httpClient.get(BldmConstant.REAL_ROOM_ID_URL + "?id=$roomId")
}) { .bodyAsText().fromJson<RealRoomId>()
val authPacket = packetEncoder.authPacket( val navData = httpClient.get(BldmConstant.USER_NAV_URL)
parsedCookie.second ?: 0, .bodyAsText().fromJson<UserNavData>().data.wbiImg
realRoomId.data.roomId, val wbi = signWbi(realRoomId.data.roomId, navData.imgUrl, navData.subUrl)
parsedCookie.first ?: "", val serverConf = httpClient.get(BldmConstant.DM_SERVER_CONF_URL + "?$wbi") {
serverConf.token cookie?.let { header(HttpHeaders.Cookie, it) }
) }.bodyAsText().fromJson<DMServerConf>().data
sendPacket(authPacket)
val heartbeatJob = launch { httpClient.webSocket(serverConf.hostList.first().tlsWsAddress) {
val heartbeatPacket = packetEncoder.heartbeatPacket() session = this
while (isActive) { val authPacket = packetEncoder.authPacket(
delay(HEARTBEAT_INTERVAL) parsedCookie.second ?: 0,
runCatching { realRoomId.data.roomId,
sendPacket(heartbeatPacket) parsedCookie.first ?: "",
}.onFailure { serverConf.token
println("heartbeat failed ${it.message}") )
sendPacket(authPacket)
val heartbeatJob = launch {
val heartbeatPacket = packetEncoder.heartbeatPacket()
while (isActive) {
delay(HEARTBEAT_INTERVAL)
runCatching {
sendPacket(heartbeatPacket)
}.onFailure {
}
} }
} }
}
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) {
if (session?.isActive == true) e.printStackTrace()
} finally {
heartbeatJob.cancel()
session = null
} }
} catch (e: Exception) {
e.printStackTrace()
} finally {
heartbeatJob.cancel()
println("Closed")
} }
} }
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) = @Suppress("FunctionName")
send(Frame.Binary(true, packet.toByteArray())) 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")
}
}
} }
} }
+1 -3
View File
@@ -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 }
@@ -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 -> {}
else -> {}
} }
} }
} }
private fun handlePayload(opCode: Int, body: ByteArray) { private suspend fun handlePayload(packetType: PacketType, body: ByteArray?) {
when (PacketType.fromCode(opCode)) { when (packetType) {
PacketType.AUTH_REPLY -> events.authReplyHandler?.invoke() PacketType.AUTH_REPLY -> dispatcher.dispatch(DanmuEvent.AuthReplyEvent)
PacketType.HEARTBEAT_REPLY -> events.heartbeatHandler?.invoke() PacketType.HEARTBEAT_REPLY -> dispatcher.dispatch(DanmuEvent.HeartbeatEvent)
PacketType.Message -> events.messageHandler?.invoke(decodeDanmuMessage(body)) PacketType.Message -> body?.let { dispatcher.dispatch(DanmuEvent.MessageEvent(it.decodeToString())) }
else -> {} else -> {}
} }
} }
private fun decodeDanmuMessage(bytes: ByteArray): RawDanmuMessage {
return RawDanmuMessage(bytes.decodeToString())
}
} }
@@ -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 {
val target = "e10adc3949ba59abbe56e057f20f883e" // 123456
@Test @Test
fun `test md5`() { fun `test self md5`() {
val target = "e10adc3949ba59abbe56e057f20f883e" // 123456 val output = "123456".digest()
assertEquals(target, output)
val res = MD5().digest("123456".encodeToByteArray()).toHexString()
assertEquals(target, res)
} }
} }
+1 -1
View File
@@ -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.
+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 * 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"
@@ -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)
@@ -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
View File
@@ -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
View File
@@ -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")