Skip to main content

bpi_rs/live/
danmaku.rs

1// --- 弹幕发送响应数据结构体 ---
2
3use crate::BilibiliRequest;
4use crate::BpiResult;
5use crate::live::LiveClient;
6use chrono::Utc;
7use reqwest::multipart::Form;
8use serde::{Deserialize, Serialize};
9
10/// 弹幕发送响应数据
11
12#[derive(Debug, Clone, Deserialize, Serialize)]
13pub struct SendDanmuData {
14    pub mode_info: Option<serde_json::Value>,
15    pub dm_v2: Option<serde_json::Value>,
16}
17
18/// 直播弹幕 WebSocket 接入点(`getDanmuInfo` 返回的 `host_list` 一项)
19#[derive(Debug, Clone, Deserialize, Serialize)]
20pub struct LiveDanmuInfoHost {
21    pub host: String,
22    #[serde(default)]
23    pub port: u32,
24    #[serde(default)]
25    pub wss_port: u32,
26    #[serde(default)]
27    pub ws_port: u32,
28}
29
30/// 直播弹幕服务器信息(WebSocket token / 接入 host)
31#[derive(Debug, Clone, Deserialize, Serialize)]
32pub struct LiveDanmuInfoData {
33    #[serde(default)]
34    pub token: String,
35    #[serde(default)]
36    pub host_list: Vec<LiveDanmuInfoHost>,
37}
38
39impl<'a> LiveClient<'a> {
40    /// 发送直播间弹幕
41    ///
42    /// # 参数
43    /// * `room_id` - 直播间 ID
44    /// * `message` - 弹幕内容
45    /// * `color` - 十进制颜色值,默认 16777215 (白色)
46    /// * `font_size` - 字体大小,默认 25
47    pub async fn live_send_danmu(
48        &self,
49        room_id: u64,
50        message: &str,
51        color: Option<u32>,
52        font_size: Option<u32>,
53    ) -> BpiResult<SendDanmuData> {
54        let csrf = self.client.csrf()?;
55        let now = Utc::now().timestamp();
56
57        // 使用 Form 构建 application/x-www-form-urlencoded 请求体
58        let mut form = Form::new()
59            .text("csrf", csrf.clone())
60            .text("roomid", room_id.to_string())
61            .text("msg", message.to_string())
62            .text("rnd", now.to_string())
63            .text("bubble", "0")
64            .text("mode", "1")
65            .text("statistics", r#"{"appId":100,"platform":5}"#)
66            .text("csrf_token", csrf); // 文档中提到 csrf_token 和 csrf 相同
67
68        if let Some(c) = color {
69            form = form.text("color", c.to_string());
70        } else {
71            form = form.text("color", "16777215"); // 默认白色
72        }
73
74        if let Some(s) = font_size {
75            form = form.text("fontsize", s.to_string());
76        } else {
77            form = form.text("fontsize", "25"); // 默认 25
78        }
79
80        self.client
81            .post("https://api.live.bilibili.com/msg/send")
82            .multipart(form)
83            .send_bpi_payload("live.danmu.send")
84            .await
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::probe::contract::HttpMethod;
92    use crate::probe::endpoint_contract::EndpointContract;
93    use crate::{ApiEnvelope, BpiResult};
94
95    fn contract() -> BpiResult<EndpointContract> {
96        EndpointContract::from_slice(include_bytes!(
97            "../../tests/contracts/live/room-interaction-read/danmu-info/contract.json"
98        ))
99    }
100
101    #[test]
102    fn live_danmu_info_contract_matches_endpoint_request() -> BpiResult<()> {
103        let contract = contract()?;
104
105        assert_eq!(contract.name, "live.danmu_info");
106        assert_eq!(contract.request.method, HttpMethod::Get);
107        assert_eq!(
108            contract.request.url.as_str(),
109            "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo"
110        );
111        assert_eq!(
112            contract.request.query.get("id").map(String::as_str),
113            Some("21733448")
114        );
115        assert_eq!(
116            contract.request.query.get("type").map(String::as_str),
117            Some("0")
118        );
119        assert!(contract.request.auth.requires_wbi());
120        assert_eq!(contract.cases.len(), 3);
121        assert_eq!(
122            contract.cases[0].response.error.as_deref(),
123            Some("wbi_risk_control")
124        );
125        assert_eq!(
126            contract.cases[1].response.rust_model.as_deref(),
127            Some("LiveDanmuInfoData")
128        );
129        Ok(())
130    }
131
132    #[test]
133    fn live_danmu_info_response_fixtures_parse_declared_model() -> BpiResult<()> {
134        let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
135            "../../tests/contracts/live/room-interaction-read/danmu-info/responses/anonymous.error.json"
136        ))?
137        .ensure_success()
138        .unwrap_err();
139        assert_eq!(err.code(), Some(-352));
140
141        let payload = ApiEnvelope::<LiveDanmuInfoData>::from_slice(include_bytes!(
142            "../../tests/contracts/live/room-interaction-read/danmu-info/responses/authenticated.success.json"
143        ))?
144        .into_payload()?;
145        assert_eq!(payload.token, "<redacted>");
146        assert_eq!(payload.host_list.len(), 1);
147        Ok(())
148    }
149
150    fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
151        let path = format!(
152            "target/bpi-probe-runs/live/room-interaction-read/danmu-info/{profile}.response.json"
153        );
154        let bytes = std::fs::read(path).ok()?;
155        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
156        value
157            .get("response")
158            .and_then(|response| response.get("body"))
159            .cloned()
160    }
161
162    #[test]
163    fn live_danmu_info_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
164        for profile in ["anonymous", "normal", "vip"] {
165            let Some(body) = local_probe_body(profile) else {
166                continue;
167            };
168            let envelope = serde_json::from_value::<ApiEnvelope<LiveDanmuInfoData>>(body)?;
169
170            if profile == "anonymous" {
171                assert_eq!(envelope.ensure_success().unwrap_err().code(), Some(-352));
172            } else {
173                let payload = envelope.into_payload()?;
174                assert!(!payload.token.is_empty());
175                assert!(!payload.host_list.is_empty());
176            }
177        }
178        Ok(())
179    }
180}