├── .gitignore ├── README.md ├── Cargo.toml ├── LICENSE └── src ├── main.rs ├── api.rs ├── packet.rs └── danmu.rs /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | # VSCode 13 | .vscode/ 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bilibili-live-danmu-rs 2 | 3 | bilibili 直播间弹幕命令行工具 4 | 5 | ## Usage 6 | 7 | ```shell 8 | bilibili-live-danmu-ctl 0.1.0 9 | JmPotato 10 | A simple bilibili live danmu ctl tool 11 | 12 | USAGE: 13 | bilibili-live-danmu-ctl --room 14 | 15 | OPTIONS: 16 | -h, --help Print help information 17 | -r, --room 18 | -V, --version Print version information 19 | ``` 20 | 21 | ## Example 22 | 23 | Example 24 | 25 | ## Reference 26 | 27 | - [SocialSisterYi/bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect) 28 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bilibili-live-danmu-ctl" 3 | version = "0.1.0" 4 | edition = "2021" 5 | authors = ["JmPotato "] 6 | description = "A simple bilibili live danmu ctl tool" 7 | readme = "README.md" 8 | repository = "https://github.com/JmPotato/bilibili-live-danmu-rs" 9 | license = "MIT" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [dependencies] 14 | async-std = {version = "1", features = ["tokio1"]} 15 | async-tls = "0.11.0" 16 | async-tungstenite = {version = "0.17", features = ["async-std-runtime", "async-tls"]} 17 | bincode = "1.3" 18 | clap = {version = "3.2.20", features = ["derive"]} 19 | colored = "2" 20 | futures = "0.3" 21 | md5 = "0.7" 22 | miniz_oxide = "0.6.1" 23 | reqwest = {version = "0.11", features = ["json"]} 24 | serde = {version = "1.0", features = ["derive"]} 25 | serde_bytes = "0.11" 26 | serde_json = "1.0" 27 | url = "2.2" 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 JmPotato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod api; 2 | mod danmu; 3 | mod packet; 4 | 5 | use async_std::task; 6 | use clap::Parser; 7 | use colored::Colorize; 8 | use danmu::{Command, LiveDanmuStream}; 9 | 10 | #[derive(Parser)] 11 | #[clap(name = "bilibili-live-danmu-ctl")] 12 | #[clap(author, version, about)] // Read from `Cargo.toml` 13 | struct Cli { 14 | #[clap(short = 'r', long = "room", value_parser)] 15 | room_id: u64, 16 | } 17 | 18 | // TODO: implement the command control. 19 | fn main() { 20 | let cli = Cli::parse(); 21 | 22 | task::block_on(async { 23 | let mut live_danmu_stream = LiveDanmuStream::new(cli.room_id); 24 | let cmd_rx = live_danmu_stream.connect().await; 25 | loop { 26 | match cmd_rx.recv().await.unwrap() { 27 | Command::Danmu(username, danmu_content) => { 28 | println!("{}: {}", username.bold(), danmu_content); 29 | } 30 | Command::InteractWord(username) => { 31 | println!("{} {}", username.bold(), "进入了直播间".italic().yellow()); 32 | } 33 | Command::SendGift(username, action, gift_name, gift_number) => { 34 | println!( 35 | "{} {}了 {} x {}", 36 | username.bold(), 37 | action, 38 | gift_name.red().bold(), 39 | gift_number 40 | ); 41 | } 42 | } 43 | } 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /src/api.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use reqwest::get; 4 | use serde::{de::DeserializeOwned, Deserialize}; 5 | 6 | type Url = String; 7 | type Params = HashMap; 8 | 9 | // TODO: use macro to generate `Request`, `URL` and `Params` automatically. 10 | pub enum Request { 11 | LiveRoomInfo(/* Room ID */ u64), 12 | LiveDanmuAuthInfo(/* Real live room ID */ u64), 13 | } 14 | 15 | impl From<&Request> for Params { 16 | fn from(request: &Request) -> Self { 17 | let mut params = Params::new(); 18 | match request { 19 | // Ref: https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/live/info.md#%E8%8E%B7%E5%8F%96%E7%9B%B4%E6%92%AD%E9%97%B4%E4%BF%A1%E6%81%AF 20 | Request::LiveRoomInfo(room_id) => { 21 | params.insert("room_id".to_string(), room_id.to_string()); 22 | } 23 | // Ref: https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/live/message_stream.md#%E8%8E%B7%E5%8F%96%E4%BF%A1%E6%81%AF%E6%B5%81%E8%AE%A4%E8%AF%81%E7%A7%98%E9%92%A5 24 | Request::LiveDanmuAuthInfo(real_room_id) => { 25 | params.insert("id".to_string(), real_room_id.to_string()); 26 | } 27 | } 28 | params 29 | } 30 | } 31 | 32 | const APP_KEY: &str = "27eb53fc9058f8c3"; 33 | const APP_SEC: &str = "c2ed53a74eeefe3cf99fbd01d8c9c375"; 34 | 35 | impl Request { 36 | // TODO: handle the Result well rather than unwrapping it all around. 37 | pub async fn request(&self) -> R { 38 | get(format!( 39 | "{}?{}", 40 | self.get_api_url(), 41 | self.signed_params(APP_KEY, APP_SEC) 42 | )) 43 | .await 44 | .unwrap() 45 | .json() 46 | .await 47 | .unwrap() 48 | } 49 | 50 | fn get_api_url(&self) -> Url { 51 | match self { 52 | Request::LiveRoomInfo(_room_id) => { 53 | "https://api.live.bilibili.com/room/v1/Room/get_info".to_string() 54 | } 55 | Request::LiveDanmuAuthInfo(_real_room_id) => { 56 | "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo".to_string() 57 | } 58 | } 59 | } 60 | 61 | /// Sign the request with the given `app_key` and `app_sec` and return the URL-encoded query string. 62 | fn signed_params(&self, app_key: &str, app_sec: &str) -> String { 63 | let mut params: Params = self.into(); 64 | params.insert("appkey".to_string(), app_key.to_string()); 65 | let mut sorted_params = params.iter().collect::>(); 66 | // Sort by the param key. 67 | sorted_params.sort_by(|a, b| a.0.cmp(b.0)); 68 | // Hash the URL encoded params. 69 | let encoded_params: String = sorted_params 70 | .iter() 71 | .map(|(k, v)| { 72 | format!( 73 | "{}={}", 74 | url::form_urlencoded::byte_serialize(k.as_bytes()).collect::(), 75 | url::form_urlencoded::byte_serialize(v.as_bytes()).collect::(), 76 | ) 77 | }) 78 | .collect::>() 79 | .join("&"); 80 | let sign = md5::compute(encoded_params.clone() + app_sec); 81 | format!("{}&sign={:x}", encoded_params, sign) 82 | } 83 | } 84 | 85 | #[allow(dead_code)] 86 | #[derive(Deserialize, Debug)] 87 | pub struct Response { 88 | code: i8, 89 | message: String, 90 | pub data: T, 91 | } 92 | 93 | #[derive(Deserialize, Debug)] 94 | pub struct LiveRoomInfoResponse { 95 | pub uid: u64, 96 | pub room_id: u64, 97 | pub live_status: i8, 98 | } 99 | 100 | #[derive(Deserialize, Debug)] 101 | pub struct LiveDanmuAuthInfoResponse { 102 | pub host_list: Vec, 103 | } 104 | 105 | #[derive(Deserialize, Debug, Clone)] 106 | pub struct HostInfo { 107 | pub host: String, 108 | pub port: u16, 109 | pub wss_port: u16, 110 | pub ws_port: u16, 111 | } 112 | 113 | impl HostInfo { 114 | pub fn get_wss_url(&self) -> String { 115 | format!("wss://{}:{}/sub", self.host, self.wss_port) 116 | } 117 | } 118 | 119 | #[cfg(test)] 120 | mod tests { 121 | use super::*; 122 | 123 | #[test] 124 | fn test_sign_request() { 125 | assert_eq!( 126 | Request::LiveDanmuAuthInfo(1) 127 | .signed_params("1d8b6e7d45233436", "560c52ccd288fed045859ed18bffd973"), 128 | "appkey=1d8b6e7d45233436&id=1&sign=0f3e35d3396fc9812b2c86942b67a275" 129 | ); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/packet.rs: -------------------------------------------------------------------------------- 1 | use async_tungstenite::tungstenite::Message; 2 | use bincode::Options; 3 | use miniz_oxide::inflate::decompress_to_vec_zlib; 4 | use serde::{Deserialize, Serialize}; 5 | use serde_json::json; 6 | 7 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] 8 | pub enum OperationCode { 9 | Unknown = 0, 10 | Heartbeat = 2, 11 | HeartbeatResponse = 3, 12 | Normal = 5, 13 | Auth = 7, 14 | AuthResponse = 8, 15 | } 16 | 17 | #[derive(Serialize, Deserialize, Debug, Clone, Copy)] 18 | struct PacketHeader { 19 | total_size: u32, 20 | header_size: u16, 21 | protocol_version: u16, 22 | operation_code: u32, 23 | sequence: u32, 24 | } 25 | 26 | const PACKET_HEADER_SIZE: usize = std::mem::size_of::(); 27 | 28 | impl PacketHeader { 29 | fn new(payload_size: usize, operation_code: OperationCode) -> Self { 30 | PacketHeader { 31 | total_size: payload_size as u32 + PACKET_HEADER_SIZE as u32, 32 | header_size: PACKET_HEADER_SIZE as u16, 33 | protocol_version: 0, 34 | operation_code: operation_code as u32, 35 | sequence: 0, 36 | } 37 | } 38 | 39 | fn from_bytes(bytes: &[u8]) -> Self { 40 | bincode::DefaultOptions::new() 41 | .with_big_endian() 42 | .with_fixint_encoding() 43 | .allow_trailing_bytes() 44 | .deserialize(&bytes[..PACKET_HEADER_SIZE]) 45 | .unwrap() 46 | } 47 | 48 | fn operation_code(&self) -> OperationCode { 49 | match self.operation_code { 50 | 2 => OperationCode::Heartbeat, 51 | 3 => OperationCode::HeartbeatResponse, 52 | 5 => OperationCode::Normal, 53 | 7 => OperationCode::Auth, 54 | 8 => OperationCode::AuthResponse, 55 | _ => OperationCode::Unknown, 56 | } 57 | } 58 | } 59 | 60 | #[derive(Serialize, Deserialize, Debug)] 61 | pub struct Packet { 62 | header: PacketHeader, 63 | json_payload: Vec, 64 | } 65 | 66 | impl Packet { 67 | pub fn new_auth_packet(uid: u64, real_room_id: u64) -> Self { 68 | let json_payload = json!({ 69 | "uid": uid, 70 | "roomid": real_room_id, 71 | }) 72 | .to_string() 73 | .into_bytes(); 74 | Self { 75 | header: PacketHeader::new(json_payload.len(), OperationCode::Auth), 76 | json_payload, 77 | } 78 | } 79 | 80 | pub fn new_heartbeat_packet() -> Self { 81 | Self { 82 | header: PacketHeader::new(0, OperationCode::Heartbeat), 83 | json_payload: Vec::new(), 84 | } 85 | } 86 | 87 | pub fn from_bytes(bytes: &[u8]) -> Self { 88 | let header = PacketHeader::from_bytes(&bytes[..PACKET_HEADER_SIZE]); 89 | Self { 90 | header, 91 | json_payload: bytes[PACKET_HEADER_SIZE..header.total_size as usize].to_vec(), 92 | } 93 | } 94 | 95 | #[inline] 96 | pub fn get_operation_code(&self) -> OperationCode { 97 | self.header.operation_code() 98 | } 99 | 100 | pub fn get_command_json(&self) -> Option> { 101 | if self.header.operation_code() != OperationCode::Normal { 102 | return None; 103 | } 104 | if self.header.total_size <= 20 { 105 | return None; 106 | } 107 | let mut commands = Vec::new(); 108 | // If the `protocol_version` is 2, it means there are multiple commands in the payload. 109 | if self.header.protocol_version == 2 { 110 | let decompressed = decompress_to_vec_zlib(&self.json_payload).unwrap(); 111 | let mut offset = 0; 112 | while offset < decompressed.len() { 113 | let header_offset = offset + PACKET_HEADER_SIZE; 114 | let header = PacketHeader::from_bytes(&decompressed[offset..header_offset]); 115 | commands.push( 116 | String::from_utf8( 117 | decompressed[header_offset..offset + header.total_size as usize].to_vec(), 118 | ) 119 | .unwrap(), 120 | ); 121 | offset += header.total_size as usize; 122 | } 123 | } else { 124 | commands.push(String::from_utf8(self.json_payload.clone()).unwrap()); 125 | } 126 | Some(commands) 127 | } 128 | } 129 | 130 | impl From for Message { 131 | fn from(mut packet: Packet) -> Self { 132 | let mut payload: Vec = Vec::with_capacity(packet.header.total_size as usize); 133 | payload.append( 134 | &mut bincode::DefaultOptions::new() 135 | .with_big_endian() 136 | .with_fixint_encoding() 137 | .serialize(&packet.header) 138 | .unwrap(), 139 | ); 140 | payload.append(&mut packet.json_payload); 141 | Message::Binary(payload) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/danmu.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use async_std::{ 4 | channel::{unbounded, Receiver, Sender}, 5 | net::TcpStream, 6 | sync::Mutex, 7 | task::{self, sleep}, 8 | }; 9 | use async_tls::client::TlsStream; 10 | use async_tungstenite::{ 11 | async_std::connect_async, stream::Stream, tungstenite::protocol::Message, WebSocketStream, 12 | }; 13 | use futures::{ 14 | stream::{SplitSink, SplitStream}, 15 | SinkExt, StreamExt, 16 | }; 17 | use serde_json::Value; 18 | 19 | use crate::{ 20 | api::{HostInfo, LiveDanmuAuthInfoResponse, LiveRoomInfoResponse, Request, Response}, 21 | packet::Packet, 22 | }; 23 | 24 | type StreamSender = SplitSink>>, Message>; 25 | type StreamReceiver = SplitStream>>>; 26 | 27 | #[derive(Debug, Default)] 28 | pub struct LiveDanmuStream { 29 | room_id: u64, 30 | real_room_id: Option, 31 | uid: Option, 32 | host_list: Option>, 33 | } 34 | 35 | impl LiveDanmuStream { 36 | pub fn new(room_id: u64) -> Self { 37 | LiveDanmuStream { 38 | room_id, 39 | ..Default::default() 40 | } 41 | } 42 | 43 | /// Prepare the live Danmu token and host list. 44 | async fn prepare(&mut self) { 45 | let room_info = Request::LiveRoomInfo(self.room_id) 46 | .request::>() 47 | .await; 48 | self.real_room_id = Some(room_info.data.room_id); 49 | self.uid = Some(room_info.data.uid); 50 | let live_danmu_auth_info = Request::LiveDanmuAuthInfo(room_info.data.room_id) 51 | .request::>() 52 | .await; 53 | self.host_list = Some(live_danmu_auth_info.data.host_list); 54 | } 55 | 56 | /// Connect to the live Danmu stream. 57 | pub async fn connect(&mut self) -> Receiver { 58 | self.prepare().await; 59 | 60 | let host_info = self.select_host(); 61 | let (ws_stream, _) = connect_async(host_info.get_wss_url()).await.unwrap(); 62 | println!("🔗 直播间弹幕服务器连接建立完成"); 63 | let (sender, receiver) = ws_stream.split(); 64 | 65 | self.heartbeat_loop(sender); 66 | println!("💗 开始发送心跳包"); 67 | 68 | let (tx, rx) = unbounded(); 69 | self.receiver_loop(receiver, tx); 70 | println!("📺 开始收取直播间消息"); 71 | rx 72 | } 73 | 74 | fn heartbeat_loop(&self, sender: StreamSender) { 75 | let uid = self.uid.unwrap(); 76 | let real_room_id = self.real_room_id.unwrap(); 77 | let sender = Mutex::new(sender); 78 | task::spawn(async move { 79 | sender 80 | .lock() 81 | .await 82 | .send(Packet::new_auth_packet(uid, real_room_id).into()) 83 | .await 84 | .unwrap(); 85 | loop { 86 | sender 87 | .lock() 88 | .await 89 | .send(Packet::new_heartbeat_packet().into()) 90 | .await 91 | .unwrap(); 92 | sleep(Duration::from_secs(30)).await; 93 | } 94 | }); 95 | } 96 | 97 | fn receiver_loop(&self, receiver: StreamReceiver, tx: Sender) { 98 | let receiver = Mutex::new(receiver); 99 | task::spawn(async move { 100 | loop { 101 | if let Message::Binary(bytes) = receiver.lock().await.next().await.unwrap().unwrap() 102 | { 103 | let packet = Packet::from_bytes(&bytes); 104 | let cmd_json_vec = packet.get_command_json(); 105 | if cmd_json_vec.is_none() { 106 | continue; 107 | } 108 | for cmd_json in cmd_json_vec.unwrap() { 109 | let cmd_value: Value = serde_json::from_str(cmd_json.as_str()).unwrap(); 110 | let cmd = match cmd_value["cmd"].as_str().unwrap() { 111 | "DANMU_MSG" => Command::Danmu( 112 | cmd_value["info"][2][1].as_str().unwrap().to_string(), 113 | cmd_value["info"][1].as_str().unwrap().to_string(), 114 | ), 115 | "INTERACT_WORD" => Command::InteractWord( 116 | cmd_value["data"]["uname"].as_str().unwrap().to_string(), 117 | ), 118 | "SEND_GIFT" => Command::SendGift( 119 | cmd_value["data"]["uname"].as_str().unwrap().to_string(), 120 | cmd_value["data"]["action"].as_str().unwrap().to_string(), 121 | cmd_value["data"]["giftName"].as_str().unwrap().to_string(), 122 | cmd_value["data"]["num"].as_u64().unwrap(), 123 | ), 124 | _ => { 125 | continue; 126 | } 127 | }; 128 | tx.send(cmd).await.unwrap(); 129 | } 130 | }; 131 | } 132 | }); 133 | } 134 | 135 | // TODO: select the best host according to the network latency. 136 | fn select_host(&self) -> HostInfo { 137 | self.host_list.as_ref().unwrap().last().unwrap().clone() 138 | } 139 | } 140 | 141 | // TODO: support more command types. 142 | pub enum Command { 143 | Danmu(/* Username */ String, /* Danmu content */ String), 144 | InteractWord(/* Username */ String), 145 | SendGift( 146 | /* Username */ String, 147 | /* Action */ String, 148 | /* Gift name */ String, 149 | /* Gift count */ u64, 150 | ), 151 | } 152 | --------------------------------------------------------------------------------