Skip to main content

bpi_rs/message/
msg.rs

1use serde::{Deserialize, Serialize};
2
3// --- API 结构体 ---
4
5/// 未读消息数
6#[derive(Debug, Clone, Deserialize, Serialize)]
7pub struct UnreadCountData {
8    pub coin: u32, // 未读投币数
9    #[serde(default)]
10    pub danmu: u32, // 未读弹幕数
11    pub favorite: u32, // 未读收藏数
12    pub recv_like: u32, // 未读收到喜欢数
13    pub recv_reply: u32, // 未读回复
14    pub sys_msg: u32, // 未读系统通知数
15    pub up: u32,   // 未读UP主助手信息数
16}
17
18/// "回复我的"信息
19#[derive(Debug, Clone, Deserialize, Serialize)]
20pub struct ReplyFeedData {
21    pub cursor: ReplyCursor,
22    pub items: Vec<ReplyItem>,
23    pub last_view_at: u64,
24}
25
26/// 分页游标
27#[derive(Debug, Clone, Deserialize, Serialize)]
28pub struct ReplyCursor {
29    pub is_end: bool,
30    pub id: Option<u64>,
31    pub time: Option<u64>,
32}
33
34/// 单条回复通知
35#[derive(Debug, Clone, Deserialize, Serialize)]
36pub struct ReplyItem {
37    pub id: u64,
38    pub user: ReplyUser,
39    pub item: ReplyDetail,
40    pub counts: u32,
41    pub is_multi: u32,
42    pub reply_time: u64,
43}
44
45/// 回复者用户信息
46#[derive(Debug, Clone, Deserialize, Serialize)]
47pub struct ReplyUser {
48    pub mid: u64,
49    pub nickname: String,
50    pub avatar: String,
51    pub follow: bool,
52    // 以下字段文档表示固定或不返回,但为了完整性保留
53    pub fans: Option<u32>,
54    pub mid_link: Option<String>,
55}
56
57/// 回复通知详情
58#[derive(Debug, Clone, Deserialize, Serialize)]
59pub struct ReplyDetail {
60    pub subject_id: u64,
61    pub root_id: u64,
62    pub source_id: u64,
63    pub target_id: u64,
64    #[serde(rename = "type")]
65    pub reply_type: String,
66    pub business_id: u32,
67    pub business: String,
68    pub title: String,
69    pub desc: String,
70    pub uri: String,
71    pub native_uri: String,
72    pub root_reply_content: String,
73    pub source_content: String,
74    pub target_reply_content: String,
75    pub at_details: Vec<AtUserDetail>,
76    pub hide_reply_button: bool,
77    pub hide_like_button: bool,
78    pub like_state: u32,
79}
80
81/// @的用户详情
82#[derive(Debug, Clone, Deserialize, Serialize)]
83pub struct AtUserDetail {
84    pub mid: u64,
85    pub nickname: String,
86    pub avatar: String,
87    pub follow: bool,
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::message::params::{MessageReplyFeedParams, MessageUnreadCountParams};
94    use crate::probe::contract::HttpMethod;
95    use crate::probe::endpoint_contract::EndpointContract;
96    use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
97
98    fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
99        let bytes = match endpoint {
100            "unread-count" => {
101                include_bytes!("../../tests/contracts/message/read/unread-count/contract.json")
102                    .as_slice()
103            }
104            "reply-feed" => {
105                include_bytes!("../../tests/contracts/message/read/reply-feed/contract.json")
106                    .as_slice()
107            }
108            _ => {
109                return Err(BpiError::invalid_parameter(
110                    "endpoint",
111                    "unknown message contract",
112                ));
113            }
114        };
115
116        EndpointContract::from_slice(bytes)
117    }
118
119    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
120    #[tokio::test]
121    async fn test_get_unread_count() -> Result<(), BpiError> {
122        let bpi = BpiClient::new().expect("client should build");
123
124        let new_data = bpi
125            .message()
126            .unread_count(MessageUnreadCountParams::new())
127            .await?;
128        println!("未读消息数 (新接口): {:?}", new_data);
129        Ok(())
130    }
131
132    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
133    #[tokio::test]
134    async fn test_get_reply_feed() -> Result<(), BpiError> {
135        let bpi = BpiClient::new().expect("client should build");
136
137        let data = bpi
138            .message()
139            .reply_feed(MessageReplyFeedParams::new())
140            .await?;
141
142        println!("最近回复我的信息:");
143        println!("  上次查看时间: {}", data.last_view_at);
144        println!("  游标信息: {:?}", data.cursor);
145
146        for item in data.items {
147            println!("---");
148            println!("  回复者: {}", item.user.nickname);
149            println!("  回复内容: {}", item.item.source_content);
150            println!("  回复时间: {}", item.reply_time);
151            println!("  关联视频/动态: {}", item.item.title);
152            println!("  根评论: {}", item.item.root_reply_content);
153            println!("  跳转链接: {}", item.item.uri);
154        }
155
156        if !data.cursor.is_end {
157            println!("---");
158            println!(
159                "还有更多数据,下次请求可使用 id: {:?}, time: {:?}",
160                data.cursor.id, data.cursor.time
161            );
162        }
163
164        Ok(())
165    }
166
167    #[test]
168    fn message_unread_count_contract_matches_endpoint_request() -> BpiResult<()> {
169        let contract = contract("unread-count")?;
170
171        assert_eq!(contract.name, "message.unread_count");
172        assert_eq!(contract.request.method, HttpMethod::Get);
173        assert_eq!(
174            contract.request.url.as_str(),
175            "https://api.vc.bilibili.com/x/im/web/msgfeed/unread"
176        );
177        assert_eq!(
178            contract.request.query.get("build").map(String::as_str),
179            Some("0")
180        );
181        assert_eq!(
182            contract.request.query.get("mobi_app").map(String::as_str),
183            Some("web")
184        );
185        assert_eq!(contract.cases.len(), 3);
186        assert_eq!(contract.cases[0].response.api_code, Some(-101));
187        assert_eq!(
188            contract.cases[0].response.error.as_deref(),
189            Some("requires_login")
190        );
191        assert_eq!(
192            contract.cases[1].response.rust_model.as_deref(),
193            Some("UnreadCountData")
194        );
195        Ok(())
196    }
197
198    #[test]
199    fn message_reply_feed_contract_matches_endpoint_request() -> BpiResult<()> {
200        let contract = contract("reply-feed")?;
201
202        assert_eq!(contract.name, "message.reply_feed");
203        assert_eq!(contract.request.method, HttpMethod::Get);
204        assert_eq!(
205            contract.request.url.as_str(),
206            "https://api.bilibili.com/x/msgfeed/reply"
207        );
208        assert_eq!(
209            contract.request.query.get("platform").map(String::as_str),
210            Some("web")
211        );
212        assert_eq!(
213            contract
214                .request
215                .query
216                .get("web_location")
217                .map(String::as_str),
218            Some("")
219        );
220        assert_eq!(contract.cases.len(), 3);
221        assert_eq!(contract.cases[0].response.api_code, Some(-101));
222        assert_eq!(
223            contract.cases[0].response.error.as_deref(),
224            Some("requires_login")
225        );
226        assert_eq!(
227            contract.cases[1].response.rust_model.as_deref(),
228            Some("ReplyFeedData")
229        );
230        Ok(())
231    }
232
233    #[test]
234    fn message_unread_count_response_fixtures_parse_declared_model() -> BpiResult<()> {
235        let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
236            "../../tests/contracts/message/read/unread-count/responses/anonymous.requires_login.json"
237        ))
238        .and_then(ApiEnvelope::ensure_success)
239        .unwrap_err();
240        assert!(err.requires_login());
241
242        let payload = ApiEnvelope::<UnreadCountData>::from_slice(include_bytes!(
243            "../../tests/contracts/message/read/unread-count/responses/authenticated.success.json"
244        ))?
245        .into_payload()?;
246
247        assert_eq!(payload.danmu, 0);
248        assert_eq!(payload.sys_msg, 1);
249        Ok(())
250    }
251
252    #[test]
253    fn message_reply_feed_response_fixtures_parse_declared_model() -> BpiResult<()> {
254        let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
255            "../../tests/contracts/message/read/reply-feed/responses/anonymous.requires_login.json"
256        ))
257        .and_then(ApiEnvelope::ensure_success)
258        .unwrap_err();
259        assert!(err.requires_login());
260
261        let payload = ApiEnvelope::<ReplyFeedData>::from_slice(include_bytes!(
262            "../../tests/contracts/message/read/reply-feed/responses/authenticated.success.json"
263        ))?
264        .into_payload()?;
265
266        assert_eq!(payload.items.len(), 1);
267        assert_eq!(payload.items[0].user.nickname, "sanitized user");
268        assert_eq!(payload.items[0].item.reply_type, "video");
269        Ok(())
270    }
271
272    fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
273        let path = format!("target/bpi-probe-runs/message/read/{endpoint}/{profile}.response.json");
274        let bytes = std::fs::read(path).ok()?;
275        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
276        value
277            .get("response")
278            .and_then(|response| response.get("body"))
279            .cloned()
280    }
281
282    #[test]
283    fn message_unread_count_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
284        for profile in ["anonymous", "normal", "vip"] {
285            let Some(body) = local_probe_body("unread-count", profile) else {
286                continue;
287            };
288
289            if profile == "anonymous" {
290                let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
291                    .ensure_success()
292                    .unwrap_err();
293                assert!(err.requires_login());
294                continue;
295            }
296
297            let payload =
298                serde_json::from_value::<ApiEnvelope<UnreadCountData>>(body)?.into_payload()?;
299            let _total_unread = payload.coin
300                + payload.danmu
301                + payload.favorite
302                + payload.recv_like
303                + payload.recv_reply
304                + payload.sys_msg
305                + payload.up;
306        }
307        Ok(())
308    }
309
310    #[test]
311    fn message_reply_feed_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
312        for profile in ["anonymous", "normal", "vip"] {
313            let Some(body) = local_probe_body("reply-feed", profile) else {
314                continue;
315            };
316
317            if profile == "anonymous" {
318                let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
319                    .ensure_success()
320                    .unwrap_err();
321                assert!(err.requires_login());
322                continue;
323            }
324
325            let payload =
326                serde_json::from_value::<ApiEnvelope<ReplyFeedData>>(body)?.into_payload()?;
327            assert!(
328                payload.cursor.is_end || payload.cursor.id.is_some() || !payload.items.is_empty()
329            );
330        }
331        Ok(())
332    }
333}