1use crate::BilibiliRequest;
4use crate::BpiError;
5use crate::BpiResult;
6use crate::message::MessageClient;
7use chrono::Utc;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use serde_json::json;
11use uuid::Uuid;
12
13#[derive(Debug, Clone, Deserialize, Serialize)]
16pub struct SingleUnreadData {
17 pub unfollow_unread: u32,
18 pub follow_unread: u32,
19 pub unfollow_push_msg: u32,
20 pub dustbin_push_msg: u32,
21 pub dustbin_unread: u32,
22 pub biz_msg_unfollow_unread: u32,
23 pub biz_msg_follow_unread: u32,
24 pub custom_unread: u32,
25}
26
27#[derive(Debug, Clone, Deserialize, Serialize)]
29pub struct SendMsgData {
30 pub msg_key: Option<u64>,
31 pub e_infos: Option<Vec<EmojiInfo>>,
32 pub msg_content: Option<String>,
33 pub key_hit_infos: Option<KeyHitInfos>,
34}
35
36#[derive(Debug, Clone, Deserialize, Serialize)]
38pub struct EmojiInfo {
39 pub text: String,
40 pub uri: String,
41 pub size: u32,
42 pub gif_url: Option<String>,
43}
44
45#[derive(Debug, Clone, Deserialize, Serialize)]
47pub struct KeyHitInfos {
48 pub toast: Option<String>,
49 pub rule_id: Option<u64>,
50 pub high_text: Option<Vec<Value>>, }
52
53#[derive(Debug, Clone, Deserialize, Serialize)]
55pub struct Image {
56 pub url: String,
57 pub height: u64,
58 pub width: u64,
59 #[serde(rename = "imageType")]
60 pub image_type: Option<String>,
61 pub original: Option<u64>, pub size: f64,
63}
64
65pub enum MessageType {
67 Text(String),
69 Image(Image),
71}
72
73pub struct MessageSendParams {
75 receiver_id: u64,
76 receiver_type: u32,
77 message_type: MessageType,
78}
79
80impl MessageSendParams {
81 pub fn new(receiver_id: u64, receiver_type: u32, message_type: MessageType) -> BpiResult<Self> {
82 if receiver_id == 0 {
83 return Err(BpiError::invalid_parameter(
84 "receiver_id",
85 "id must be non-zero",
86 ));
87 }
88 if !matches!(receiver_type, 1 | 2) {
89 return Err(BpiError::invalid_parameter(
90 "receiver_type",
91 "value must be 1 or 2",
92 ));
93 }
94
95 Ok(Self {
96 receiver_id,
97 receiver_type,
98 message_type,
99 })
100 }
101}
102
103impl<'a> MessageClient<'a> {
104 pub async fn send(&self, params: MessageSendParams) -> BpiResult<SendMsgData> {
106 let csrf = self.client.csrf()?;
107 let sender_uid = &self
108 .client
109 .get_account()
110 .ok_or(BpiError::auth("未登录"))?
111 .dede_user_id;
112 let dev_id = Uuid::new_v4().to_string();
113 let timestamp = Utc::now().timestamp();
114
115 let msg_type = match ¶ms.message_type {
116 MessageType::Text(_) => 1,
117 MessageType::Image(_) => 2,
118 };
119
120 let mut form = vec![
121 ("msg[sender_uid]", sender_uid.to_string()),
122 ("msg[receiver_id]", params.receiver_id.to_string()),
123 ("msg[receiver_type]", params.receiver_type.to_string()),
124 ("msg[msg_type]", msg_type.to_string()),
125 ("msg[msg_status]", "0".to_string()),
126 ("msg[dev_id]", dev_id.clone()),
127 ("msg[timestamp]", timestamp.to_string()),
128 ("msg[new_face_version]", "1".to_string()),
129 ("csrf", csrf.clone()),
130 ("csrf_token", csrf.clone()),
131 ("build", "0".to_string()),
132 ("mobi_app", "web".to_string()),
133 ];
134
135 let content = match params.message_type {
136 MessageType::Text(text) => json!({ "content": text }).to_string(),
137 MessageType::Image(image) => serde_json::to_string(&image)?,
138 };
139
140 form.push(("msg[content]", content));
141
142 let params = vec![
143 ("w_sender_uid", sender_uid.to_string()),
144 ("w_receiver_id", params.receiver_id.to_string()),
145 ("w_dev_id", dev_id.clone()),
146 ];
147
148 let signed_params = self.client.get_wbi_sign2(params).await?;
149
150 self.client
152 .post("https://api.vc.bilibili.com/web_im/v1/web_im/send_msg")
153 .query(&signed_params)
154 .form(&form)
155 .send_bpi_payload("message.private.send")
156 .await
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 use crate::probe::contract::HttpMethod;
165 use crate::probe::endpoint_contract::EndpointContract;
166 use crate::{ApiEnvelope, BpiResult};
167
168 fn contract() -> BpiResult<EndpointContract> {
169 EndpointContract::from_slice(include_bytes!(
170 "../../tests/contracts/message/read/single-unread/contract.json"
171 ))
172 }
173
174 #[test]
175 fn message_single_unread_contract_matches_endpoint_request() -> BpiResult<()> {
176 let contract = contract()?;
177
178 assert_eq!(contract.name, "message.single_unread");
179 assert_eq!(contract.request.method, HttpMethod::Get);
180 assert_eq!(
181 contract.request.url.as_str(),
182 "https://api.vc.bilibili.com/session_svr/v1/session_svr/single_unread"
183 );
184 assert_eq!(
185 contract.request.query.get("build").map(String::as_str),
186 Some("0")
187 );
188 assert_eq!(
189 contract.request.query.get("mobi_app").map(String::as_str),
190 Some("web")
191 );
192 assert_eq!(
193 contract
194 .request
195 .query
196 .get("unread_type")
197 .map(String::as_str),
198 Some("0")
199 );
200 assert_eq!(
201 contract
202 .request
203 .query
204 .get("show_unfollow_list")
205 .map(String::as_str),
206 Some("0")
207 );
208 assert_eq!(
209 contract
210 .request
211 .query
212 .get("show_dustbin")
213 .map(String::as_str),
214 Some("0")
215 );
216 assert_eq!(contract.cases.len(), 3);
217 assert_eq!(contract.cases[0].response.api_code, Some(-101));
218 assert_eq!(
219 contract.cases[0].response.error.as_deref(),
220 Some("requires_login")
221 );
222 assert_eq!(
223 contract.cases[1].response.rust_model.as_deref(),
224 Some("SingleUnreadData")
225 );
226 Ok(())
227 }
228
229 #[test]
230 fn message_single_unread_response_fixtures_parse_declared_model() -> BpiResult<()> {
231 let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
232 "../../tests/contracts/message/read/single-unread/responses/anonymous.requires_login.json"
233 ))
234 .and_then(ApiEnvelope::ensure_success)
235 .unwrap_err();
236 assert!(err.requires_login());
237
238 let payload = ApiEnvelope::<SingleUnreadData>::from_slice(include_bytes!(
239 "../../tests/contracts/message/read/single-unread/responses/authenticated.success.json"
240 ))?
241 .into_payload()?;
242
243 assert_eq!(payload.follow_unread, 0);
244 assert_eq!(payload.unfollow_unread, 0);
245 Ok(())
246 }
247
248 fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
249 let path =
250 format!("target/bpi-probe-runs/message/read/single-unread/{profile}.response.json");
251 let bytes = std::fs::read(path).ok()?;
252 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
253 value
254 .get("response")
255 .and_then(|response| response.get("body"))
256 .cloned()
257 }
258
259 #[test]
260 fn message_single_unread_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
261 for profile in ["anonymous", "normal", "vip"] {
262 let Some(body) = local_probe_body(profile) else {
263 continue;
264 };
265
266 if profile == "anonymous" {
267 let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
268 .ensure_success()
269 .unwrap_err();
270 assert!(err.requires_login());
271 continue;
272 }
273
274 let payload =
275 serde_json::from_value::<ApiEnvelope<SingleUnreadData>>(body)?.into_payload()?;
276 let _total_unread =
277 payload.follow_unread + payload.unfollow_unread + payload.biz_msg_follow_unread;
278 }
279 Ok(())
280 }
281}