Skip to main content

bpi_rs/message/
private_msg.rs

1// --- API 结构体 ---
2
3use 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/// 未读私信数数据
14
15#[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/// 发送私信的响应数据
28#[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/// 表情信息
37#[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/// 触发的提示信息
46#[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>>, // 具体结构待补充
51}
52
53/// 发送的图片格式
54#[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>, // 1 代表是原图
62    pub size: f64,
63}
64
65/// 私信消息类型
66pub enum MessageType {
67    /// 文本消息,内容为纯文本
68    Text(String),
69    /// 图片消息,内容为JSON字符串
70    Image(Image),
71}
72
73/// Parameters for sending a private message.
74pub 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    /// Sends a private message and returns the canonical payload result.
105    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 &params.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        // 发送请求
151        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}