From fe49e4ff6346385ae4274d2401e0c3d3b3c8289e Mon Sep 17 00:00:00 2001 From: RTAkland Date: Mon, 31 Aug 2026 15:43:44 +0800 Subject: [PATCH] Introduce event dispatcher and fix heartbeat, auth reply events --- .gitignore | 1 - README.md | 6 + bl-client/build.gradle.kts | 6 +- .../kotlin/cn/rtast/bldm/client/bldm.kt | 148 ++++++----- .../kotlin/cn/rtast/bldm/client/util/_json.kt | 20 ++ .../rtast/bldm/client/util/ws_binary_send.kt | 15 ++ .../src/commonTest/kotlin/test/TestClient.kt | 26 +- bl-codec/build.gradle.kts | 4 +- .../bldm/codec/annotations/InternalBldmApi.kt | 13 - .../cn/rtast/bldm/codec/data/UserNavData.kt | 93 ------- .../bldm/codec/data/protocol/AuthPayload.kt | 30 ++- .../codec/data/protocol/PacketMetadata.kt | 17 -- .../codec/data/protocol/RawDanmuMessage.kt | 13 - .../cn/rtast/bldm/codec/event/DanmuEvent.kt | 14 + .../bldm/codec/event/DanmuEventDispatcher.kt | 25 ++ .../cn/rtast/bldm/codec/protocol/Packet.kt | 3 + .../rtast/bldm/codec/protocol/PacketType.kt | 35 ++- .../bldm/codec/protocol/ProtocolVersion.kt | 19 +- .../codec/protocol/codec/PacketDecoder.kt | 57 ++-- .../codec/protocol/codec/PacketEncoder.kt | 13 +- .../bldm/codec/protocol/event/PacketEvents.kt | 28 -- .../kotlin/cn/rtast/bldm/codec/util/_md5.kt | 248 +++++++++++++++++- .../kotlin/cn/rtast/bldm/codec/util/json.kt | 30 --- .../cn/rtast/bldm/codec/util/wbi_signer.kt | 58 ++++ .../src/commonTest/kotlin/test/TestMd5.kt | 13 +- bl-dm-codec/build.gradle.kts | 2 +- .../src/commonMain/resources/danmu-dump.jsonl | 11 + bl-model/build.gradle.kts | 28 ++ .../kotlin/cn/rtast/bldm/dto/BldmConstant.kt | 9 +- .../kotlin/cn/rtast/bldm/dto}/DMServerConf.kt | 10 +- .../kotlin/cn/rtast/bldm/dto}/RealRoomId.kt | 2 +- .../kotlin/cn/rtast/bldm/dto/UserNavData.kt | 31 +++ gradle.properties | 2 +- settings.gradle.kts | 1 + 34 files changed, 686 insertions(+), 345 deletions(-) create mode 100644 bl-client/src/commonMain/kotlin/cn/rtast/bldm/client/util/_json.kt create mode 100644 bl-client/src/commonMain/kotlin/cn/rtast/bldm/client/util/ws_binary_send.kt delete mode 100644 bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/annotations/InternalBldmApi.kt delete mode 100644 bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/UserNavData.kt delete mode 100644 bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/protocol/PacketMetadata.kt delete mode 100644 bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/protocol/RawDanmuMessage.kt create mode 100644 bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/event/DanmuEvent.kt create mode 100644 bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/event/DanmuEventDispatcher.kt delete mode 100644 bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/event/PacketEvents.kt delete mode 100644 bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/util/json.kt create mode 100644 bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/util/wbi_signer.kt create mode 100644 bl-dm-codec/src/commonMain/resources/danmu-dump.jsonl create mode 100644 bl-model/build.gradle.kts rename bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/constant.kt => bl-model/src/commonMain/kotlin/cn/rtast/bldm/dto/BldmConstant.kt (74%) rename {bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data => bl-model/src/commonMain/kotlin/cn/rtast/bldm/dto}/DMServerConf.kt (78%) rename {bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data => bl-model/src/commonMain/kotlin/cn/rtast/bldm/dto}/RealRoomId.kt (91%) create mode 100644 bl-model/src/commonMain/kotlin/cn/rtast/bldm/dto/UserNavData.kt diff --git a/.gitignore b/.gitignore index 9a6cfe6..77bc300 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,3 @@ bin/ .DS_Store /.idea/ /bl-client/src/commonTest/resources/cookie.txt -/bl-client/src/commonTest/resources/res.txt diff --git a/README.md b/README.md index 787fb42..a058156 100644 --- a/README.md +++ b/README.md @@ -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) \ No newline at end of file diff --git a/bl-client/build.gradle.kts b/bl-client/build.gradle.kts index 396a53c..64d00d2 100644 --- a/bl-client/build.gradle.kts +++ b/bl-client/build.gradle.kts @@ -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}") } diff --git a/bl-client/src/commonMain/kotlin/cn/rtast/bldm/client/bldm.kt b/bl-client/src/commonMain/kotlin/cn/rtast/bldm/client/bldm.kt index 68398b1..d4e958b 100644 --- a/bl-client/src/commonMain/kotlin/cn/rtast/bldm/client/bldm.kt +++ b/bl-client/src/commonMain/kotlin/cn/rtast/bldm/client/bldm.kt @@ -4,88 +4,108 @@ * 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, -) { - val parsedCookie = parseBuvidAndUid(cookie ?: "") - val packetEncoder = PacketEncoder() - val packetDecoder = PacketDecoder(events) - val realRoomId = httpClient.get(BLDMConstants.REAL_ROOM_ID_URL + roomId) - .bodyAsText()._fromJson() - val wbi = httpClient.get(BLDMConstants.USER_NAV_URL) - .bodyAsText()._fromJson().signWbi(realRoomId.data.roomId) - val serverConf = httpClient.get(BLDMConstants.DM_SERVER_CONF_URL + "?$wbi") { - cookie?.let { header(HttpHeaders.Cookie, it) } - }.bodyAsText()._fromJson().data - val wsUrl = "ws://${serverConf.hostList.first().host}:${serverConf.hostList.first().wsPort}/sub" + 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 - httpClient.webSocket(wsUrl, { -// header(HttpHeaders.Host, "${serverConf.hostList.first().host}:${serverConf.hostList.first().wssPort}") -// header(HttpHeaders.Origin, "https://www.bilibili.com") - }) { - val authPacket = packetEncoder.authPacket( - parsedCookie.second ?: 0, - realRoomId.data.roomId, - parsedCookie.first ?: "", - serverConf.token - ) - sendPacket(authPacket) - val heartbeatJob = launch { - val heartbeatPacket = packetEncoder.heartbeatPacket() - while (isActive) { - delay(HEARTBEAT_INTERVAL) - runCatching { - sendPacket(heartbeatPacket) - }.onFailure { - println("heartbeat failed ${it.message}") + private suspend fun internalConnect() { + val parsedCookie = parseBuvidAndUid(cookie ?: "") + val realRoomId = httpClient.get(BldmConstant.REAL_ROOM_ID_URL + "?id=$roomId") + .bodyAsText().fromJson() + val navData = httpClient.get(BldmConstant.USER_NAV_URL) + .bodyAsText().fromJson().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().data + + httpClient.webSocket(serverConf.hostList.first().tlsWsAddress) { + session = this + val authPacket = packetEncoder.authPacket( + parsedCookie.second ?: 0, + realRoomId.data.roomId, + parsedCookie.first ?: "", + serverConf.token + ) + sendPacket(authPacket) + val heartbeatJob = launch { + val heartbeatPacket = packetEncoder.heartbeatPacket() + while (isActive) { + delay(HEARTBEAT_INTERVAL) + runCatching { + sendPacket(heartbeatPacket) + }.onFailure { + } } } - } - try { - for (frame in incoming) { - if (frame !is Frame.Binary) continue - val data = frame.readBytes() - packetDecoder.decode(data) + + try { + for (frame in incoming) { + if (frame !is Frame.Binary) continue + val data = frame.readBytes() + 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) = - send(Frame.Binary(true, packet.toByteArray())) \ No newline at end of file +@Suppress("FunctionName") +public fun BLDMClient(roomId: Long, cookie: String? = null): BldmClient = + BldmClient(roomId, cookie) \ No newline at end of file diff --git a/bl-client/src/commonMain/kotlin/cn/rtast/bldm/client/util/_json.kt b/bl-client/src/commonMain/kotlin/cn/rtast/bldm/client/util/_json.kt new file mode 100644 index 0000000..d022057 --- /dev/null +++ b/bl-client/src/commonMain/kotlin/cn/rtast/bldm/client/util/_json.kt @@ -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 String.fromJson(): T = _json.decodeFromString(this) + +internal inline fun T.encodeJson(): String = _json.encodeToString(this) \ No newline at end of file diff --git a/bl-client/src/commonMain/kotlin/cn/rtast/bldm/client/util/ws_binary_send.kt b/bl-client/src/commonMain/kotlin/cn/rtast/bldm/client/util/ws_binary_send.kt new file mode 100644 index 0000000..845154a --- /dev/null +++ b/bl-client/src/commonMain/kotlin/cn/rtast/bldm/client/util/ws_binary_send.kt @@ -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())) \ No newline at end of file diff --git a/bl-client/src/commonTest/kotlin/test/TestClient.kt b/bl-client/src/commonTest/kotlin/test/TestClient.kt index 984e99d..bf9fcf4 100644 --- a/bl-client/src/commonTest/kotlin/test/TestClient.kt +++ b/bl-client/src/commonTest/kotlin/test/TestClient.kt @@ -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 { println("Heartbeat") } + client.on { println("AuthReply") } + client.on { 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) +// } } } \ No newline at end of file diff --git a/bl-codec/build.gradle.kts b/bl-codec/build.gradle.kts index 21ba4d7..ac49950 100644 --- a/bl-codec/build.gradle.kts +++ b/bl-codec/build.gradle.kts @@ -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 { diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/annotations/InternalBldmApi.kt b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/annotations/InternalBldmApi.kt deleted file mode 100644 index b2d3411..0000000 --- a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/annotations/InternalBldmApi.kt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/UserNavData.kt b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/UserNavData.kt deleted file mode 100644 index 77ea550..0000000 --- a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/UserNavData.kt +++ /dev/null @@ -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.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()}") - } - } -} \ No newline at end of file diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/protocol/AuthPayload.kt b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/protocol/AuthPayload.kt index 94320ff..f813217 100644 --- a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/protocol/AuthPayload.kt +++ b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/protocol/AuthPayload.kt @@ -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 -) \ No newline at end of file + 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") +} \ No newline at end of file diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/protocol/PacketMetadata.kt b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/protocol/PacketMetadata.kt deleted file mode 100644 index 49382da..0000000 --- a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/protocol/PacketMetadata.kt +++ /dev/null @@ -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 -) \ No newline at end of file diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/protocol/RawDanmuMessage.kt b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/protocol/RawDanmuMessage.kt deleted file mode 100644 index 3c72a0a..0000000 --- a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/protocol/RawDanmuMessage.kt +++ /dev/null @@ -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) \ No newline at end of file diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/event/DanmuEvent.kt b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/event/DanmuEvent.kt new file mode 100644 index 0000000..12cf42e --- /dev/null +++ b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/event/DanmuEvent.kt @@ -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 +} \ No newline at end of file diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/event/DanmuEventDispatcher.kt b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/event/DanmuEventDispatcher.kt new file mode 100644 index 0000000..58418ef --- /dev/null +++ b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/event/DanmuEventDispatcher.kt @@ -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, MutableList Unit>> = + mutableMapOf() + + public suspend fun dispatch(event: DanmuEvent) { + eventHandlers[event::class]?.forEach { it.invoke(event) } + } + + public inline fun on(crossinline block: (T) -> Unit) { + val handlers = eventHandlers.getOrPut(T::class) { mutableListOf() } + handlers.add { event -> block(event as T) } + } +} \ No newline at end of file diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/Packet.kt b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/Packet.kt index db5ea16..a781170 100644 --- a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/Packet.kt +++ b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/Packet.kt @@ -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) diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/PacketType.kt b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/PacketType.kt index 4b3a068..19ed207 100644 --- a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/PacketType.kt +++ b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/PacketType.kt @@ -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 } diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/ProtocolVersion.kt b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/ProtocolVersion.kt index 9c3a01e..23986b3 100644 --- a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/ProtocolVersion.kt +++ b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/ProtocolVersion.kt @@ -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 } diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/codec/PacketDecoder.kt b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/codec/PacketDecoder.kt index bc9e802..b84e39a 100644 --- a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/codec/PacketDecoder.kt +++ b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/codec/PacketDecoder.kt @@ -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 -> {} + + else -> {} } } } - 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)) + 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 -> {} } } - - private fun decodeDanmuMessage(bytes: ByteArray): RawDanmuMessage { - return RawDanmuMessage(bytes.decodeToString()) - } } \ No newline at end of file diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/codec/PacketEncoder.kt b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/codec/PacketEncoder.kt index 074000a..b18357d 100644 --- a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/codec/PacketEncoder.kt +++ b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/codec/PacketEncoder.kt @@ -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 + ) } \ No newline at end of file diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/event/PacketEvents.kt b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/event/PacketEvents.kt deleted file mode 100644 index 99098c0..0000000 --- a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/protocol/event/PacketEvents.kt +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/util/_md5.kt b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/util/_md5.kt index 63eac9a..a439ff8 100644 --- a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/util/_md5.kt +++ b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/util/_md5.kt @@ -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() \ No newline at end of file + 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) \ No newline at end of file diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/util/json.kt b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/util/json.kt deleted file mode 100644 index 009fe99..0000000 --- a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/util/json.kt +++ /dev/null @@ -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 String._fromJson(): T = _json.decodeFromString(this) - -@InternalBldmApi -public inline fun T._encodeJson(): String = _json.encodeToString(this) \ No newline at end of file diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/util/wbi_signer.kt b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/util/wbi_signer.kt new file mode 100644 index 0000000..eec4c5d --- /dev/null +++ b/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/util/wbi_signer.kt @@ -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.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()}") + } +} \ No newline at end of file diff --git a/bl-codec/src/commonTest/kotlin/test/TestMd5.kt b/bl-codec/src/commonTest/kotlin/test/TestMd5.kt index 1acfc77..8b9a927 100644 --- a/bl-codec/src/commonTest/kotlin/test/TestMd5.kt +++ b/bl-codec/src/commonTest/kotlin/test/TestMd5.kt @@ -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 { + val target = "e10adc3949ba59abbe56e057f20f883e" // 123456 + @Test - fun `test md5`() { - val target = "e10adc3949ba59abbe56e057f20f883e" // 123456 - - val res = MD5().digest("123456".encodeToByteArray()).toHexString() - - assertEquals(target, res) + fun `test self md5`() { + val output = "123456".digest() + assertEquals(target, output) } } \ No newline at end of file diff --git a/bl-dm-codec/build.gradle.kts b/bl-dm-codec/build.gradle.kts index af7a795..9852ae4 100644 --- a/bl-dm-codec/build.gradle.kts +++ b/bl-dm-codec/build.gradle.kts @@ -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") } diff --git a/bl-dm-codec/src/commonMain/resources/danmu-dump.jsonl b/bl-dm-codec/src/commonMain/resources/danmu-dump.jsonl new file mode 100644 index 0000000..8452d26 --- /dev/null +++ b/bl-dm-codec/src/commonMain/resources/danmu-dump.jsonl @@ -0,0 +1,11 @@ +{"cmd":"SEND_GIFT_V2","danmu":{"area":1},"data":{"dmscore":14,"pb":"CNiSgJDHwtMGEgzkuIvkvY/lnLDmraIaSmh0dHBzOi8vaTEuaGRzbGIuY29tL2Jmcy9mYWNlL2M4YTVhMWZiOWI3YzY4NGYxYTllODg0YjU4ZjVhMjg5MTc4NmE5NGUuanBnQgBS1gUIv/IBEgzniZvlk4fniZvlk4cYASABKGQwZDhkQgRnb2xkShM0ODExNDczMTYxNDMyNDEwMTEyUMGl0NQGWCJiQ2JhdGNoOmdpZnQ6Y29tYm9faWQ6Mzc0NTAyNDQxNjE1NTk5Mjo1MDMyOTExODozMTAzOToxNzg4MDg4OTI2Ljc5MzJoCnDIGngFhQHNzIw/kgEG5oqV5ZaCwAGpuLSeAuoBJQoe5ZOU5ZOp5ZOU5ZOp6Iux6ZuE6IGU55uf6LWb5LqLEJ7s/xeKApoCCJ7s/xcSkgIKHuWTlOWTqeWTlOWTqeiLsembhOiBlOebn+i1m+S6ixJKaHR0cHM6Ly9pMi5oZHNsYi5jb20vYmZzL2ZhY2UvNTQ0Yzg5ZTY4ZjJiMWYxMmZmY2JiOGIzYzA2MmEzMzI4ZTg2OTJkOS5qcGcybAoe5ZOU5ZOp5ZOU5ZOp6Iux6ZuE6IGU55uf6LWb5LqLEkpodHRwczovL2kyLmhkc2xiLmNvbS9iZnMvZmFjZS81NDRjODllNjhmMmIxZjEyZmZjYmI4YjNjMDYyYTMzMjhlODY5MmQ5LmpwZzo2CAMSMOWTlOWTqeWTlOWTqeiLsembhOiBlOebn+i1m+S6i+ebtOaSreWumOaWuei0puWPtyABkgIAmgLlAQpKaHR0cHM6Ly9zMS5oZHNsYi5jb20vYmZzL2xpdmUvOTFhYzhlMzVkZDkzYTcxOTYzMjVmMWUyMDUyMzU2ZTcxZDEzNWFmYi5wbmcSS2h0dHBzOi8vaTAuaGRzbGIuY29tL2Jmcy9saXZlLzc1NzFjOGJkZmNiNjVmOWIwMGFiMTMwN2IyYzg2NjMwZjBkMDA4NTkud2VicCpKaHR0cHM6Ly9pMC5oZHNsYi5jb20vYmZzL2xpdmUvMTc5ZjQyOTQ2YzFmMjY4MDcxODAwYWNhMTdmNzc0MzY1NGFmNzE3Ny5naWaqAgBYAWoCCAN6zwEI2JKAkMfC0wYSwwEKDOS4i+S9j+WcsOatohJKaHR0cHM6Ly9pMS5oZHNsYi5jb20vYmZzL2ZhY2UvYzhhNWExZmI5YjdjNjg0ZjFhOWU4ODRiNThmNWEyODkxNzg2YTk0ZS5qcGcyWgoM5LiL5L2P5Zyw5q2iEkpodHRwczovL2kxLmhkc2xiLmNvbS9iZnMvZmFjZS9jOGA1YTFmYjliN2M2ODRmM1E5ZTg4NGI1OGY1YTI4OTE3ODZhOTRlLmpwZzoLIP///////////wE="}} +{"cmd":"WATCHED_CHANGE","data":{"num":305251371,"text_small":"30525.1万","text_large":"30525.1万人看过"}} +{"cmd":"ONLINE_RANK_V3","data":{"pb":"CgtvbmxpbmVfcmFuaxqRAwiwrOqDARJKaHR0cHM6Ly9pMC5oZHNsYi5jb20vYmZzL2ZhY2UvYzY2YzAxM2E1YzA9MTA4M2E2YzUyNDI3MWRkMDEyNDMyODkxNzE2ZC5qcGcaAjgwIgzlgZrlkqnprLzlkYAoAUKoAgiwrOqDARKfAgoM5YGa5ZKp6ay85ZGAEkpodHRwczovL2kwLmhkc2xiLmNvbS9iZnMvZmFjZS9jNjZjMDEzYTVjMDkxMDgzYTZjNTI0MjcxZGQwMTI0MzI4OTE3MTZkLmpwZypaCgzlgZrlkqnprLzlkYASSmh0dHBzOi8vaTAuaGRzbGIuY29tL2Jmcy9mYWNlL2M2NmMwMTNhNWMwOTE0ODNhNmM1MjQyNzFkZDAxMjQzMjg5MTcxNmQuanBnMloKDOWBmuWSqemsvOWRgBJKaHR0cHM6Ly9pMC5oZHNsYi5jb20vYmZzL2ZhY2UvYzY2YzAxM2E1YzA5MTA4M2E2YzUyNDI3MWRkMDEyNDMyODkxNzE2ZC5qcGc6CyD///////////8BGp0DCK+Um4YBEkpodHRwczovL2kxLmhkc2xiLmNvbS9iZnMvZmFjZS9lZWI0MWYxMmEzOWI0NTMxYTBlY2E4MTg1ZjBhMDMyNTVjNTQ5YzhiLmpwZxoCNjAiD+eLoeeMvueahOWkqeS9vygCQrECCK+Um4YBEqgCCg/ni6HnjL7nmoTlpKnkvb8SSmh0dHBzOi8vaTEuaGRzbGIuY29tL2Jmcy9mYWNlL2VlYjQxFjEyY3A0NTMxYTBlY2E4MTg1ZjBhMDMyNTVjNTQ5YzhiLmpwZyppChvml6DmrLLml6DmsYLnmoTnvr3muKHlsJjkuLYSSmh0dHBzOi8vaTIuaGRzbGIuY29tL2Jmcy9mYWNlLzIzY2JiNDBmMzJiZTE0ZGRjZTU1MjE6NzhjMWYxZjM0MTIwOTNlY3M6CyD///////////8B"}} +{"cmd":"LIKE_INFO_V3_UPDATE","data":{"click_count":742806}} +{"cmd":"ONLINE_RANK_V2","data":{"list":[{"uid":276469296,"face":"https://i0.hdslb.com/bfs/face/c66c013a5c091083a6c524271dd012432891716d.jpg","score":"80","uname":"做咩鬼呀","rank":1,"guard_level":0,"is_mystery":false,"uinfo":{"uid":276469296,"base":{"name":"做咩鬼呀","face":"https://i0.hdslb.com/bfs/face/c66c013a5c091083a6c524271dd012432891716d.jpg","name_color":0,"is_mystery":false,"risk_ctrl_info":{"name":"做咩鬼呀","face":"https://i0.hdslb.com/bfs/face/c66c013a5c091083a6c524271dd012432891716d.jpg"},"origin_info":{"name":"做咩鬼呀","face":"https://i0.hdslb.com/bfs/face/c66c013a5c091083a6c524271dd012432891716d.jpg"},"official_info":{"role":0,"title":"","desc":"","type":-1},"name_color_str":""},"medal":null,"wealth":null,"title":null,"guard":null,"uhead_frame":null,"guard_leader":null,"anon":null,"bubble_box":null,"dm_config":null,"name_color":null}},{"uid":281463343,"face":"https://i1.hdslb.com/bfs/face/eeb41f12a39b4531a0eca8185f0a03255c549c8b.jpg","score":"60","uname":"狡猾的天使","rank":2,"guard_level":0,"is_mystery":false,"uinfo":{"uid":281463343,"base":{"name":"狡猾的天使","face":"https://i1.hdslb.com/bfs/face/eeb41f12a39b4531a0eca8185f0a03255c549c8b.jpg","name_color":0,"is_mystery":false,"risk_ctrl_info":{"name":"狡猾的天使","face":"https://i1.hdslb.com/bfs/face/eeb41f12a39b4531a0eca8185f0a03255c549c8b.jpg"},"origin_info":{"name":"狡猾的天使","face":"https://i1.hdslb.com/bfs/face/eeb41f12a39b4531a0eca8185f0a03255c549c8b.jpg"},"official_info":{"role":0,"title":"","desc":"","type":-1},"name_color_str":""},"medal":null,"wealth":null,"title":null,"guard":null,"uhead_frame":null,"guard_leader":null,"anon":null,"bubble_box":null,"dm_config":null,"name_color":null}},{"uid":3745024416155992,"face":"https://i1.hdslb.com/bfs/face/c8a5a1fb9b7c684f1a9e884b58f5a2891786a94e.jpg","score":"35","uname":"下住地止","rank":3,"guard_level":0,"is_mystery":false,"uinfo":{"uid":3745024416155992,"base":{"name":"下住地止","face":"https://i1.hdslb.com/bfs/face/c8a5a1fb9b7c684f1a9e884b58f5a2891786a94e.jpg","name_color":0,"is_mystery":false,"risk_ctrl_info":{"name":"下住地止","face":"https://i1.hdslb.com/bfs/face/c8a5a1fb9b7c684f1a9e884b58f5a2891786a94e.jpg"},"origin_info":{"name":"下住地止","face":"https://i1.hdslb.com/bfs/face/c8a5a1fb9b7c684f1a9e884b58f5a2891786a94e.jpg"},"official_info":{"role":0,"title":"","desc":"","type":-1},"name_color_str":""},"medal":null,"wealth":null,"title":null,"guard":null,"uhead_frame":null,"guard_leader":null,"anon":null,"bubble_box":null,"dm_config":null,"name_color":null}},{"uid":649966124,"face":"https://i2.hdslb.com/bfs/face/798eba5454173b3327b4ae8d8ea1cf34f96e1ff1.jpg","score":"31","uname":"春风若有丶","rank":4,"guard_level":0,"is_mystery":false,"uinfo":{"uid":649966124,"base":{"name":"春风若有丶","face":"https://i2.hdslb.com/bfs/face/798eba5454173b3327b4ae8d8ea1cf34f96e1ff1.jpg","name_color":0,"is_mystery":false,"risk_ctrl_info":{"name":"春风若有丶","face":"https://i2.hdslb.com/bfs/face/798eba5454173b3327b4ae8d8ea1cf34f96e1ff1.jpg"},"origin_info":{"name":"春风若有丶","face":"https://i2.hdslb.com/bfs/face/798eba5454173b3327b4ae8d8ea1cf34f96e1ff1.jpg"},"official_info":{"role":0,"title":"","desc":"","type":-1},"name_color_str":""},"medal":null,"wealth":null,"title":null,"guard":null,"uhead_frame":null,"guard_leader":null,"anon":null,"bubble_box":null,"dm_config":null,"name_color":null}},{"uid":17369046,"face":"https://i0.hdslb.com/bfs/face/017d84d9d18ed792d98c9f269ed5b2623bcf7544.jpg","score":"30","uname":"禾柚yo","rank":5,"guard_level":0,"is_mystery":false,"uinfo":{"uid":17369046,"base":{"name":"禾柚yo","face":"https://i0.hdslb.com/bfs/face/017d84d9d18ed792d98c9f269ed5b2623bcf7544.jpg","name_color":0,"is_mystery":false,"risk_ctrl_info":{"name":"禾柚yo","face":"https://i0.hdslb.com/bfs/face/017d84d9d18ed792d98c9f269ed5b2623bcf7544.jpg"},"origin_info":{"name":"禾柚yo","face":"https://i0.hdslb.com/bfs/face/017d84d9d18ed792d98c9f269ed5b2623bcf7544.jpg"},"official_info":{"role":0,"title":"","desc":"","type":-1},"name_color_str":""},"medal":null,"wealth":null,"title":null,"guard":null,"uhead_frame":null,"guard_leader":null,"anon":null,"bubble_box":null,"dm_config":null,"name_color":null}},{"uid":1869405479,"face":"https://i2.hdslb.com/bfs/face/3b98cca0bba320712ed4beeca082e98c25f721be.jpg","score":"22","uname":"鸿初暖","rank":6,"guard_level":0,"is_mystery":false,"uinfo":{"uid":1869405479,"base":{"name":"鸿初暖","face":"https://i2.hdslb.com/bfs/face/3b98cca0bba320712ed4beeca082e98c25f721be.jpg","name_color":0,"is_mystery":false,"risk_ctrl_info":{"name":"鸿初暖","face":"https://i2.hdslb.com/bfs/face/3b98cca0bba320712ed4beeca082e98c25f721be.jpg"},"origin_info":{"name":"鸿初暖","face":"https://i2.hdslb.com/bfs/face/3b98cca0bba320712ed4beeca082e98c25f721be.jpg"},"official_info":{"role":0,"title":"","desc":"","type":-1},"name_color_str":""},"medal":null,"wealth":null,"title":null,"guard":null,"uhead_frame":null,"guard_leader":null,"anon":null,"bubble_box":null,"dm_config":null,"name_color":null}},{"uid":276657965,"face":"https://i2.hdslb.com/bfs/face/23cbb40f32be14ddce5521678c1f1f3412093eb3.jpg","score":"20","uname":"无欲无求的羽渡尘丶","rank":7,"guard_level":0,"is_mystery":false,"uinfo":{"uid":276657965,"base":{"name":"无欲无求的羽渡尘丶","face":"https://i2.hdslb.com/bfs/face/23cbb40f32be14ddce5521678c1f1f3412093eb3.jpg","name_color":0,"is_mystery":false,"risk_ctrl_info":{"name":"无欲无求的羽渡尘丶","face":"https://i2.hdslb.com/bfs/face/23cbb40f32be14ddce5521678c1f1f3412093eb3.jpg"},"origin_info":{"name":"无欲无求的羽渡尘丶","face":"https://i2.hdslb.com/bfs/face/23cbb40f32be14ddce5521678c1f1f3412093eb3.jpg"},"official_info":{"role":0,"title":"","desc":"","type":-1},"name_color_str":""},"medal":null,"wealth":null,"title":null,"guard":null,"uhead_frame":null,"guard_leader":null,"anon":null,"bubble_box":null,"dm_config":null,"name_color":null}}],"rank_type":"gold-rank"}} +{"cmd":"DANMU_MSG","dm_v2":"","info":[[0,1,25,16777215,1788089029205,1788085400,0,"98c603b4",0,0,0,"",0,"{}","{}",{"extra":"{\"send_from_me\":false,\"master_player_hidden\":false,\"mode\":0,\"color\":16777215,\"dm_type\":0,\"font_size\":25,\"player_mode\":1,\"show_player_type\":0,\"content\":\"为什么不打啊\",\"user_hash\":\"2563113908\",\"emoticon_unique\":\"\",\"bulge_display\":0,\"recommend_score\":1,\"dm_score\":0,\"chronos_force_display\":0,\"main_state_dm_color\":\"\",\"objective_state_dm_color\":\"\",\"direction\":0,\"pk_direction\":0,\"quartet_direction\":0,\"anniversary_crowd\":0,\"yeah_space_type\":\"\",\"yeah_space_url\":\"\",\"jump_to_url\":\"\",\"space_type\":\"\",\"space_url\":\"\",\"animation\":{},\"emots\":null,\"is_audited\":false,\"id_str\":\"1bf24896189e630e667727f89c6a9412913\",\"icon\":null,\"show_reply\":false,\"reply_mid\":0,\"reply_uname\":\"\",\"reply_uname_color\":\"\",\"reply_is_mystery\":false,\"reply_type_enum\":0,\"hit_combo\":0,\"esports_jump_url\":\"\",\"is_mirror\":false,\"is_collaboration_member\":false,\"card\":{\"card_type\":0,\"oid_str\":\"\",\"oid_str_1\":\"\",\"origin_oid_str\":\"\",\"share_id\":\"\",\"share_origin\":\"\",\"from\":\"\",\"card_content\":null},\"voice\":null,\"background_type\":0}","mode":0,"show_player_type":0,"user":{"anon":null,"base":{"face":"https://i1.hdslb.com/bfs/face/e5bac55e4966aabd3445d1626ea154f57417ad6d.jpg","is_mystery":false,"name":"尊嘟假嘟QAQ","name_color":0,"name_color_str":"","official_info":{"desc":"","role":0,"title":"","type":-1},"origin_info":{"face":"https://i1.hdslb.com/bfs/face/e5bac55e4966aabd3445d1626ea154f57417ad6d.jpg","name":"尊嘟假嘟QAQ"},"risk_ctrl_info":null},"bubble_box":null,"dm_config":{"color":16777215,"length":40,"mode":1},"guard":null,"guard_leader":null,"medal":null,"name_color":null,"title":{"old_title_css_id":"","title_css_id":""},"uhead_frame":null,"uid":1624828753,"wealth":null}},{"activity_identity":"","activity_source":0,"not_show":0},0],"为什么不打啊",[1624828753,"尊嘟假嘟QAQ",0,0,0,10000,1,""],[],[0,0,9868950,"\u003e50000",0],["",""],0,0,null,{"ct":"A71F8851","ts":1788089029},0,0,null,null,0,13,[0],null]} +{"cmd":"LIKE_INFO_V3_CLICK","data":{"contribution_info":{"grade":0},"dmscore":6,"fans_medal":{"anchor_roomid":0,"guard_level":0,"icon_id":0,"is_lighted":0,"medal_color":6067854,"medal_color_border":12632256,"medal_color_end":12632256,"medal_color_start":12632256,"medal_level":3,"medal_name":"小h鱼","score":9,"special":"","target_id":383053366},"group_medal":null,"identities":[1],"is_mystery":false,"like_icon":"https://i0.hdslb.com/bfs/live/23678e3d90402bea6a65251b3e728044c21b1f0f.png","like_text":"为主播点赞了","msg_type":6,"show_area":0,"uid":676923624,"uinfo":{"anon":null,"base":{"face":"https://i0.hdslb.com/bfs/face/7a31a2bb3d8016de06f93d6de576961e587bec26.jpg","is_mystery":false,"name":"无聊冰淇淋蛋糕","name_color":0,"name_color_str":"","official_info":{"desc":"","role":0,"title":"","type":-1},"origin_info":{"face":"https://i0.hdslb.com/bfs/face/7a31a2bb3d8016de06f93d6de576961e587bec26.jpg","name":"无聊冰淇淋蛋糕"},"risk_ctrl_info":null},"guard":null,"guard_leader":null,"medal":{"color":6067854,"color_border":12632256,"color_end":12632256,"color_start":12632256,"guard_icon":"","guard_level":0,"honor_icon":"","id":0,"is_light":0,"level":3,"name":"小h鱼","ruid":383053366,"score":9,"typ":0,"user_receive_count":0,"v2_medal_color_border":"#919298CC","v2_medal_color_end":"#919298CC","v2_medal_color_level":"#919298E6","v2_medal_color_start":"#919298CC","v2_medal_color_text":"#FFFFFF"},"title":null,"uhead_frame":null,"uid":676923624,"wealth":null},"uname":"无聊冰淇淋蛋糕","uname_color":""}} +{"cmd":"ONLINE_RANK_COUNT","data":{"count":9999,"count_text":"9999+","online_count":9999,"online_count_text":"9999+"}} +{"cmd":"RECALL_DANMU_MSG","data":{"recall_type":2,"target_id":8286279,"uinfo":{"uid":8286279,"base":null,"medal":null,"wealth":null,"title":null,"guard":null,"uhead_frame":null,"guard_leader":null,"anon":null,"bubble_box":null,"dm_config":null,"name_color":null}}} +{"cmd":"INTERACT_WORD_V2","data":{"dmscore":1,"pb":"CLe/rwkSG+aaguaXtuayoeaDs+WlveS7peWQjuWPiOivtCIBASgBMLiH2AM4zaXQ1AZA+K+mk4U0SgBQAVoHI0ZGNjQ5RWIAag/mtYHph4/ljIXmjqjlub94nuKks5awpOgYmgEAsgHnAQi3v68JEt8BChvmmoLml7bmsqHmg7Plpb3ku6XlkI7lj4ior7QSSWh0dHA6Ly9p0Lmhkc2xiLmNvbS9iZnMvZmFjZS8wMmI2ZmFmMzExZjhiNmYzOWQ5Njc3YTEzMTFhM2RiNTZkYjBmMDYuanBnOgsg////////////AboBAMIBAA=="}} +{"cmd":"STOP_LIVE_ROOM_LIST","data":{"room_id_list":[1732568433,1826344982,1895289464,32713042,10918697,30973528,1726459015,1992273124,1735737911,1741043675,1700309566,1712257331,11664476,1852636821,5714029,1880723604,1905054093,1992690423,1905050296,1922175631,24058651,1917951986,31341874,8138925,22678216,1824994787,1886441643,1933934477,21588200,9373058,1811338318,1741040270,1794701575,1963696169,23556296,243122,26185596,1811338967,1905052025,1746237353,1849309599,1949307570,31497616,23343252,30529487,7265489,10346100,189672,26672945,31131338,898030,1854984409,1980129525,21195703,83326,9211361,1707581,1748635768,1957026384,4978879,1890519727,1905059417,21256478,1943674591,31470443,31846250,1357495,3028178,1733589679,25229899,31191848,1721766400,1820703176,4664282,1965471533,24099185,5283426]}} \ No newline at end of file diff --git a/bl-model/build.gradle.kts b/bl-model/build.gradle.kts new file mode 100644 index 0000000..3e0a8c1 --- /dev/null +++ b/bl-model/build.gradle.kts @@ -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") + } + } +} \ No newline at end of file diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/constant.kt b/bl-model/src/commonMain/kotlin/cn/rtast/bldm/dto/BldmConstant.kt similarity index 74% rename from bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/constant.kt rename to bl-model/src/commonMain/kotlin/cn/rtast/bldm/dto/BldmConstant.kt index 41069c8..b770222 100644 --- a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/constant.kt +++ b/bl-model/src/commonMain/kotlin/cn/rtast/bldm/dto/BldmConstant.kt @@ -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" diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/DMServerConf.kt b/bl-model/src/commonMain/kotlin/cn/rtast/bldm/dto/DMServerConf.kt similarity index 78% rename from bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/DMServerConf.kt rename to bl-model/src/commonMain/kotlin/cn/rtast/bldm/dto/DMServerConf.kt index 92ab17a..8342fc5 100644 --- a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/DMServerConf.kt +++ b/bl-model/src/commonMain/kotlin/cn/rtast/bldm/dto/DMServerConf.kt @@ -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) diff --git a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/RealRoomId.kt b/bl-model/src/commonMain/kotlin/cn/rtast/bldm/dto/RealRoomId.kt similarity index 91% rename from bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/RealRoomId.kt rename to bl-model/src/commonMain/kotlin/cn/rtast/bldm/dto/RealRoomId.kt index a1bf1a4..8b850d9 100644 --- a/bl-codec/src/commonMain/kotlin/cn/rtast/bldm/codec/data/RealRoomId.kt +++ b/bl-model/src/commonMain/kotlin/cn/rtast/bldm/dto/RealRoomId.kt @@ -5,7 +5,7 @@ */ -package cn.rtast.bldm.codec.data +package cn.rtast.bldm.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/bl-model/src/commonMain/kotlin/cn/rtast/bldm/dto/UserNavData.kt b/bl-model/src/commonMain/kotlin/cn/rtast/bldm/dto/UserNavData.kt new file mode 100644 index 0000000..8e44593 --- /dev/null +++ b/bl-model/src/commonMain/kotlin/cn/rtast/bldm/dto/UserNavData.kt @@ -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, + ) +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index ed1edd3..bf25ecc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 \ No newline at end of file +libVersion=0.1.0 \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 58027b6..878b206 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,6 @@ rootProject.name = "bldm" include(":bl-codec") +include(":bl-model") include(":bl-client") include(":bl-dm-codec") \ No newline at end of file