1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Deserialize, Serialize)]
7pub struct UnreadCountData {
8 pub coin: u32, #[serde(default)]
10 pub danmu: u32, pub favorite: u32, pub recv_like: u32, pub recv_reply: u32, pub sys_msg: u32, pub up: u32, }
17
18#[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#[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#[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#[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 pub fans: Option<u32>,
54 pub mid_link: Option<String>,
55}
56
57#[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#[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}