1use crate::BilibiliRequest;
2use crate::BpiError;
3use crate::BpiResult;
4use crate::ids::{Mid, RoomId};
5use crate::live::LiveClient;
6use serde::{Deserialize, Deserializer, Serialize};
7
8#[derive(Debug, Serialize, Clone, Deserialize)]
9pub struct SilentUserInfo {
10 pub tuid: i64,
12 pub tname: String,
14 pub uid: i64,
16 pub name: String,
18 pub ctime: String,
20 pub id: i64,
22 pub is_anchor: i8,
24 pub face: String,
26 pub msg: String,
28 pub admin_level: i8,
30 pub is_mystery: bool,
32 pub block_end_time: String,
34 pub r#type: i8,
36}
37
38#[derive(Debug, Serialize, Clone, Deserialize)]
39pub struct SilentUserListData {
40 #[serde(default, deserialize_with = "deserialize_vec_or_default")]
42 pub data: Vec<SilentUserInfo>,
43 pub total: i32,
45 #[serde(default)]
47 pub total_page: i32,
48 #[serde(default)]
50 pub pn: i32,
51 #[serde(default)]
53 pub ps: i32,
54}
55
56#[derive(Debug, Serialize, Clone, Deserialize)]
57pub struct BannedUserInfo {
58 pub uid: i64,
60 pub mtime: String,
62 pub face: String,
64 pub name: String,
66 pub is_anchor: bool,
68 pub operator_name: String,
70 pub admin_level: i8,
72 pub is_mystery: bool,
74}
75
76#[derive(Debug, Serialize, Clone, Deserialize)]
77pub struct BannedUserListData {
78 #[serde(default, deserialize_with = "deserialize_vec_or_default")]
80 pub data: Vec<BannedUserInfo>,
81 pub total: i32,
83 #[serde(default)]
85 pub total_page: i32,
86 #[serde(default)]
88 pub pn: i32,
89 #[serde(default)]
91 pub ps: i32,
92}
93
94#[derive(Debug, Serialize, Clone, Deserialize)]
95pub struct ShieldKeywordInfo {
96 pub keyword: String,
98 pub uid: i64,
100 pub name: String,
102 pub is_anchor: i8,
104}
105
106#[derive(Debug, Serialize, Clone, Deserialize)]
107pub struct ShieldKeywordListData {
108 #[serde(default, deserialize_with = "deserialize_vec_or_default")]
110 pub keyword_list: Vec<ShieldKeywordInfo>,
111 pub max_limit: i32,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct LiveSilentUserListParams {
117 room_id: RoomId,
118 page: u32,
119 page_size: u32,
120}
121
122impl LiveSilentUserListParams {
123 pub fn new(room_id: RoomId) -> Self {
124 Self {
125 room_id,
126 page: 1,
127 page_size: 10,
128 }
129 }
130
131 pub fn page(mut self, page: u32) -> BpiResult<Self> {
132 self.page = validate_positive_u32("pn", page)?;
133 Ok(self)
134 }
135
136 pub fn page_size(mut self, page_size: u32) -> BpiResult<Self> {
137 self.page_size = validate_positive_u32("ps", page_size)?;
138 Ok(self)
139 }
140
141 pub(crate) fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
142 vec![
143 ("room_id", self.room_id.to_string()),
144 ("pn", self.page.to_string()),
145 ("ps", self.page_size.to_string()),
146 ("csrf_token", csrf.to_string()),
147 ("csrf", csrf.to_string()),
148 ]
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct LiveBannedUserListParams {
154 anchor_id: Mid,
155 page: u32,
156 page_size: u32,
157}
158
159impl LiveBannedUserListParams {
160 pub fn new(anchor_id: Mid) -> Self {
161 Self {
162 anchor_id,
163 page: 1,
164 page_size: 10,
165 }
166 }
167
168 pub fn page(mut self, page: u32) -> BpiResult<Self> {
169 self.page = validate_positive_u32("pn", page)?;
170 Ok(self)
171 }
172
173 pub fn page_size(mut self, page_size: u32) -> BpiResult<Self> {
174 self.page_size = validate_positive_u32("ps", page_size)?;
175 Ok(self)
176 }
177
178 pub(crate) fn query_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
179 vec![
180 ("anchor_id", self.anchor_id.to_string()),
181 ("pn", self.page.to_string()),
182 ("ps", self.page_size.to_string()),
183 ("mobi_app", "android".to_string()),
184 ("platform", "android".to_string()),
185 ("spmid", "444.8.0.0".to_string()),
186 ("csrf_token", csrf.to_string()),
187 ("csrf", csrf.to_string()),
188 ("visit_id", String::new()),
189 ]
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct LiveShieldKeywordListParams {
195 room_id: RoomId,
196}
197
198impl LiveShieldKeywordListParams {
199 pub fn new(room_id: RoomId) -> Self {
200 Self { room_id }
201 }
202
203 pub(crate) fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
204 vec![
205 ("room_id", self.room_id.to_string()),
206 ("spmid", "444.8.0.0".to_string()),
207 ("csrf_token", csrf.to_string()),
208 ("csrf", csrf.to_string()),
209 ("visit_id", String::new()),
210 ("mobi_app", "android".to_string()),
211 ("platform", "android".to_string()),
212 ]
213 }
214}
215
216fn validate_positive_u32(field: &'static str, value: u32) -> BpiResult<u32> {
217 if value == 0 {
218 return Err(BpiError::invalid_parameter(field, "value must be non-zero"));
219 }
220
221 Ok(value)
222}
223
224fn deserialize_vec_or_default<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
225where
226 D: Deserializer<'de>,
227 T: Deserialize<'de>,
228{
229 Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
230}
231
232impl<'a> LiveClient<'a> {
233 pub async fn live_add_silent_user(
238 &self,
239 room_id: i64,
240 tuid: i64,
241 hour: i32,
242 msg: Option<String>,
243 ) -> BpiResult<Option<serde_json::Value>> {
244 let csrf = self.client.csrf()?;
245
246 let form = vec![
247 ("room_id", room_id.to_string()),
248 ("tuid", tuid.to_string()),
249 ("msg", msg.unwrap_or_default()),
250 ("mobile_app", "web".to_string()),
251 (
252 "type",
253 if hour == 0 {
254 "2".to_string()
255 } else {
256 "1".to_string()
257 },
258 ),
259 ("hour", hour.to_string()),
260 ("csrf_token", csrf.clone()),
261 ("csrf", csrf),
262 ];
263
264 self.client
269 .post("https://api.live.bilibili.com/xlive/web-ucenter/v1/banned/AddSilentUser")
270 .form(&form)
271 .send_bpi_optional_payload("live.silent_user.add")
272 .await
273 }
274
275 pub async fn live_del_block_user(
278 &self,
279 roomid: i64,
280 tuid: i64,
281 ) -> BpiResult<Option<serde_json::Value>> {
282 let csrf = self.client.csrf()?;
283
284 let form = vec![
285 ("room_id", roomid.to_string()),
286 ("tuid", tuid.to_string()),
287 ("csrf_token", csrf.clone()),
288 ("csrf", csrf),
289 ];
290
291 self.client
292 .post("https://api.live.bilibili.com/xlive/web-ucenter/v1/banned/DelSilentUser")
293 .form(&form)
294 .send_bpi_optional_payload("live.silent_user.delete")
295 .await
296 }
297
298 pub async fn live_add_banned_user(
301 &self,
302 room_id: i64,
303 anchor_id: i64,
304 tuid: i64,
305 ) -> BpiResult<Option<serde_json::Value>> {
306 let csrf = self.client.csrf()?;
307
308 let form = vec![
309 ("tuid", tuid.to_string()),
310 ("anchor_id", anchor_id.to_string()),
311 ("spmid", "444.8.0.0".to_string()),
312 ("csrf_token", csrf.clone()),
313 ("csrf", csrf),
314 ("visit_id", "".to_string()),
315 ];
316
317 self.client
318 .post("https://api.live.bilibili.com/xlive/app-ucenter/v2/xbanned/banned/AddBlack")
319 .header("Referer", format!("https://live.bilibili.com/{}", room_id))
320 .form(&form)
321 .send_bpi_optional_payload("live.banned_user.add")
322 .await
323 }
324
325 pub async fn live_del_banned_user(
328 &self,
329 room_id: i64,
330 anchor_id: i64,
331 tuid: i64,
332 ) -> BpiResult<Option<serde_json::Value>> {
333 let csrf = self.client.csrf()?;
334
335 let form = vec![
336 ("tuid", tuid.to_string()),
337 ("anchor_id", anchor_id.to_string()),
338 ("spmid", "444.8.0.0".to_string()),
339 ("csrf_token", csrf.clone()),
340 ("csrf", csrf),
341 ("visit_id", "".to_string()),
342 ("mobi_app", "android".to_string()),
343 ("platform", "android".to_string()),
344 ];
345
346 self.client
347 .post("https://api.live.bilibili.com/xlive/app-ucenter/v2/xbanned/banned/DelBlack")
348 .header("Referer", format!("https://live.bilibili.com/{}", room_id))
349 .form(&form)
350 .send_bpi_optional_payload("live.banned_user.delete")
351 .await
352 }
353
354 pub async fn live_add_shield_keyword(
357 &self,
358 room_id: i64,
359 keyword: String,
360 ) -> BpiResult<Option<serde_json::Value>> {
361 let csrf = self.client.csrf()?;
362
363 let form = vec![
364 ("keyword", keyword),
365 ("room_id", room_id.to_string()),
366 ("spmid", "444.8.0.0".to_string()),
367 ("csrf_token", csrf.clone()),
368 ("csrf", csrf),
369 ("visit_id", "".to_string()),
370 ("mobi_app", "android".to_string()),
371 ("platform", "android".to_string()),
372 ];
373
374 self.client
375 .post("https://api.live.bilibili.com/xlive/app-ucenter/v1/banned/AddShieldKeyword")
376 .form(&form)
377 .send_bpi_optional_payload("live.shield_keyword.add")
378 .await
379 }
380
381 pub async fn live_del_shield_keyword(
384 &self,
385 room_id: i64,
386 keyword: String,
387 ) -> BpiResult<Option<serde_json::Value>> {
388 let csrf = self.client.csrf()?;
389
390 let form = vec![
391 ("keyword", keyword),
392 ("room_id", room_id.to_string()),
393 ("spmid", "444.8.0.0".to_string()),
394 ("csrf_token", csrf.clone()),
395 ("csrf", csrf),
396 ("visit_id", "".to_string()),
397 ("mobi_app", "android".to_string()),
398 ("platform", "android".to_string()),
399 ];
400
401 self.client
402 .post("https://api.live.bilibili.com/xlive/app-ucenter/v1/banned/DelShieldKeyword")
403 .form(&form)
404 .send_bpi_optional_payload("live.shield_keyword.delete")
405 .await
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::probe::contract::HttpMethod;
413 use crate::probe::endpoint_contract::EndpointContract;
414 use crate::{ApiEnvelope, BpiResult};
415
416 fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
417 let bytes = match endpoint {
418 "silent-users" => include_bytes!(
419 "../../tests/contracts/live/moderation-private-read/silent-users/contract.json"
420 )
421 .as_slice(),
422 "banned-users" => include_bytes!(
423 "../../tests/contracts/live/moderation-private-read/banned-users/contract.json"
424 )
425 .as_slice(),
426 "shield-keywords" => include_bytes!(
427 "../../tests/contracts/live/moderation-private-read/shield-keywords/contract.json"
428 )
429 .as_slice(),
430 _ => unreachable!("unknown live moderation contract endpoint"),
431 };
432
433 EndpointContract::from_slice(bytes)
434 }
435
436 fn room_id() -> RoomId {
437 RoomId::new(3_818_081).expect("test room id should be valid")
438 }
439
440 fn anchor_id() -> Mid {
441 Mid::new(4_279_370).expect("test anchor id should be valid")
442 }
443
444 #[test]
445 fn live_moderation_params_reject_zero_pagination() {
446 let err = LiveSilentUserListParams::new(room_id())
447 .page(0)
448 .unwrap_err();
449 assert!(matches!(
450 err,
451 BpiError::InvalidParameter { field: "pn", .. }
452 ));
453
454 let err = LiveBannedUserListParams::new(anchor_id())
455 .page_size(0)
456 .unwrap_err();
457 assert!(matches!(
458 err,
459 BpiError::InvalidParameter { field: "ps", .. }
460 ));
461 }
462
463 #[test]
464 fn live_moderation_contracts_match_endpoint_requests() -> BpiResult<()> {
465 let silent_users = contract("silent-users")?;
466 let banned_users = contract("banned-users")?;
467 let shield_keywords = contract("shield-keywords")?;
468
469 assert_eq!(silent_users.name, "live.silent_users");
470 assert_eq!(silent_users.request.method, HttpMethod::Post);
471 assert_eq!(
472 silent_users.request.url.as_str(),
473 "https://api.live.bilibili.com/xlive/web-ucenter/v1/banned/GetSilentUserList"
474 );
475 let silent_params = LiveSilentUserListParams::new(room_id())
476 .page(1)?
477 .page_size(10)?;
478 assert_eq!(
479 silent_params.form_pairs("${csrf}"),
480 vec![
481 ("room_id", "3818081".to_string()),
482 ("pn", "1".to_string()),
483 ("ps", "10".to_string()),
484 ("csrf_token", "${csrf}".to_string()),
485 ("csrf", "${csrf}".to_string()),
486 ]
487 );
488
489 assert_eq!(banned_users.name, "live.banned_users");
490 assert_eq!(banned_users.request.method, HttpMethod::Get);
491 assert_eq!(
492 banned_users.request.url.as_str(),
493 "https://api.live.bilibili.com/xlive/app-ucenter/v2/xbanned/banned/GetBlackList"
494 );
495 let banned_params = LiveBannedUserListParams::new(anchor_id())
496 .page(1)?
497 .page_size(10)?;
498 assert_eq!(
499 banned_params.query_pairs("${csrf}"),
500 vec![
501 ("anchor_id", "4279370".to_string()),
502 ("pn", "1".to_string()),
503 ("ps", "10".to_string()),
504 ("mobi_app", "android".to_string()),
505 ("platform", "android".to_string()),
506 ("spmid", "444.8.0.0".to_string()),
507 ("csrf_token", "${csrf}".to_string()),
508 ("csrf", "${csrf}".to_string()),
509 ("visit_id", String::new()),
510 ]
511 );
512
513 assert_eq!(shield_keywords.name, "live.shield_keywords");
514 assert_eq!(shield_keywords.request.method, HttpMethod::Post);
515 assert_eq!(
516 shield_keywords.request.url.as_str(),
517 "https://api.live.bilibili.com/xlive/app-ucenter/v1/banned/GetShieldKeywordList"
518 );
519 assert_eq!(
520 LiveShieldKeywordListParams::new(room_id()).form_pairs("${csrf}"),
521 vec![
522 ("room_id", "3818081".to_string()),
523 ("spmid", "444.8.0.0".to_string()),
524 ("csrf_token", "${csrf}".to_string()),
525 ("csrf", "${csrf}".to_string()),
526 ("visit_id", String::new()),
527 ("mobi_app", "android".to_string()),
528 ("platform", "android".to_string()),
529 ]
530 );
531
532 assert_eq!(silent_users.cases.len(), 3);
533 assert_eq!(banned_users.cases.len(), 3);
534 assert_eq!(shield_keywords.cases.len(), 3);
535 Ok(())
536 }
537
538 #[test]
539 fn live_moderation_response_fixtures_parse_declared_models() -> BpiResult<()> {
540 let anonymous = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
541 "../../tests/contracts/live/moderation-private-read/silent-users/responses/anonymous.requires_login.json"
542 ))?
543 .ensure_success()
544 .unwrap_err();
545 assert!(anonymous.requires_login());
546
547 let not_admin = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
548 "../../tests/contracts/live/moderation-private-read/silent-users/responses/normal.not_admin.json"
549 ))?
550 .ensure_success()
551 .unwrap_err();
552 assert_eq!(not_admin.code(), Some(100_004));
553
554 let silent_users = ApiEnvelope::<SilentUserListData>::from_slice(include_bytes!(
555 "../../tests/contracts/live/moderation-private-read/silent-users/responses/vip.empty.success.json"
556 ))?
557 .into_payload()?;
558 assert_eq!(silent_users.total, 0);
559
560 let banned_empty = ApiEnvelope::<BannedUserListData>::from_slice(include_bytes!(
561 "../../tests/contracts/live/moderation-private-read/banned-users/responses/normal.empty.success.json"
562 ))?
563 .into_payload()?;
564 assert_eq!(banned_empty.total, 0);
565
566 let banned_sample = ApiEnvelope::<BannedUserListData>::from_slice(include_bytes!(
567 "../../tests/contracts/live/moderation-private-read/banned-users/responses/vip.sample.success.json"
568 ))?
569 .into_payload()?;
570 assert_eq!(banned_sample.total, 1);
571 assert_eq!(banned_sample.data[0].name, "<redacted-user>");
572
573 let permission_denied = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
574 "../../tests/contracts/live/moderation-private-read/shield-keywords/responses/normal.permission_denied.json"
575 ))?
576 .ensure_success()
577 .unwrap_err();
578 assert_eq!(permission_denied.code(), Some(100_007));
579
580 let shield_keywords = ApiEnvelope::<ShieldKeywordListData>::from_slice(include_bytes!(
581 "../../tests/contracts/live/moderation-private-read/shield-keywords/responses/vip.empty.success.json"
582 ))?
583 .into_payload()?;
584 assert_eq!(shield_keywords.max_limit, 1000);
585 Ok(())
586 }
587
588 fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
589 let path = format!(
590 "target/bpi-probe-runs/live/moderation-private-read/{endpoint}/{profile}.response.json"
591 );
592 let bytes = std::fs::read(path).ok()?;
593 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
594 value
595 .get("response")
596 .and_then(|response| response.get("body"))
597 .cloned()
598 }
599
600 #[test]
601 fn live_moderation_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
602 for profile in ["anonymous", "normal", "vip"] {
603 if let Some(body) = local_probe_body("silent-users", profile) {
604 let envelope = serde_json::from_value::<ApiEnvelope<SilentUserListData>>(body)?;
605 match profile {
606 "anonymous" => assert!(envelope.ensure_success().unwrap_err().requires_login()),
607 "normal" => {
608 assert_eq!(envelope.ensure_success().unwrap_err().code(), Some(100_004));
609 }
610 _ => {
611 let payload = envelope.into_payload()?;
612 assert!(payload.total >= 0);
613 }
614 }
615 }
616
617 if let Some(body) = local_probe_body("banned-users", profile) {
618 let envelope = serde_json::from_value::<ApiEnvelope<BannedUserListData>>(body)?;
619 if profile == "anonymous" {
620 assert!(envelope.ensure_success().unwrap_err().requires_login());
621 } else {
622 let payload = envelope.into_payload()?;
623 assert!(payload.total >= 0);
624 }
625 }
626
627 if let Some(body) = local_probe_body("shield-keywords", profile) {
628 let envelope = serde_json::from_value::<ApiEnvelope<ShieldKeywordListData>>(body)?;
629 match profile {
630 "anonymous" => assert!(envelope.ensure_success().unwrap_err().requires_login()),
631 "normal" => {
632 assert_eq!(envelope.ensure_success().unwrap_err().code(), Some(100_007));
633 }
634 _ => {
635 let payload = envelope.into_payload()?;
636 assert!(payload.max_limit >= 0);
637 }
638 }
639 }
640 }
641 Ok(())
642 }
643}