1use crate::BilibiliRequest;
4use crate::BpiResult;
5use crate::live::LiveClient;
6use chrono::Utc;
7use reqwest::multipart::Form;
8use serde::{Deserialize, Serialize};
9
10#[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#[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#[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 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 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); if let Some(c) = color {
69 form = form.text("color", c.to_string());
70 } else {
71 form = form.text("color", "16777215"); }
73
74 if let Some(s) = font_size {
75 form = form.text("fontsize", s.to_string());
76 } else {
77 form = form.text("fontsize", "25"); }
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}