Skip to main content

bpi_rs/live/
silent_user_manage.rs

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    /// 禁言者uid
11    pub tuid: i64,
12    /// 禁言者昵称
13    pub tname: String,
14    /// 发起者uid
15    pub uid: i64,
16    /// 发起者昵称
17    pub name: String,
18    /// 禁言时间
19    pub ctime: String,
20    /// 禁言记录Id
21    pub id: i64,
22    /// 是否是房主禁言的,0否,1是
23    pub is_anchor: i8,
24    /// 禁言者头像
25    pub face: String,
26    /// 禁言理由
27    pub msg: String,
28    /// 发起者权限
29    pub admin_level: i8,
30    /// 是否注销
31    pub is_mystery: bool,
32    /// 禁言结束时间,空代表永久或本场禁言
33    pub block_end_time: String,
34    /// 禁言模式,0代表永久,1代表正常,2代表本场禁言
35    pub r#type: i8,
36}
37
38#[derive(Debug, Serialize, Clone, Deserialize)]
39pub struct SilentUserListData {
40    /// 禁言列表
41    #[serde(default, deserialize_with = "deserialize_vec_or_default")]
42    pub data: Vec<SilentUserInfo>,
43    /// 禁言观众数量
44    pub total: i32,
45    /// 页码总数量,只有一页的时候没有
46    #[serde(default)]
47    pub total_page: i32,
48    /// 页码,只有一页的时候没有
49    #[serde(default)]
50    pub pn: i32,
51    /// 上限,只有一页的时候没有
52    #[serde(default)]
53    pub ps: i32,
54}
55
56#[derive(Debug, Serialize, Clone, Deserialize)]
57pub struct BannedUserInfo {
58    /// 拉黑者uid
59    pub uid: i64,
60    /// 拉黑时间
61    pub mtime: String,
62    /// 拉黑者头像
63    pub face: String,
64    /// 拉黑者昵称
65    pub name: String,
66    /// 是否是房主拉黑的
67    pub is_anchor: bool,
68    /// 发起者昵称
69    pub operator_name: String,
70    /// 发起者权限
71    pub admin_level: i8,
72    /// 是否注销
73    pub is_mystery: bool,
74}
75
76#[derive(Debug, Serialize, Clone, Deserialize)]
77pub struct BannedUserListData {
78    /// 拉黑列表
79    #[serde(default, deserialize_with = "deserialize_vec_or_default")]
80    pub data: Vec<BannedUserInfo>,
81    /// 拉黑观众数量
82    pub total: i32,
83    /// 页码总数量,只有一页的时候没有,由于接口不返回,所以默认0
84    #[serde(default)]
85    pub total_page: i32,
86    /// 上限,只有一页的时候没有,由于接口不返回,所以默认0
87    #[serde(default)]
88    pub pn: i32,
89    /// 页码,只有一页的时候没有,由于接口不返回,所以默认0
90    #[serde(default)]
91    pub ps: i32,
92}
93
94#[derive(Debug, Serialize, Clone, Deserialize)]
95pub struct ShieldKeywordInfo {
96    /// 违禁词
97    pub keyword: String,
98    /// 添加者uid
99    pub uid: i64,
100    /// 添加者昵称
101    pub name: String,
102    /// 是否是房主添加的,0否,1是
103    pub is_anchor: i8,
104}
105
106#[derive(Debug, Serialize, Clone, Deserialize)]
107pub struct ShieldKeywordListData {
108    /// 违禁词列表
109    #[serde(default, deserialize_with = "deserialize_vec_or_default")]
110    pub keyword_list: Vec<ShieldKeywordInfo>,
111    /// 数量上限
112    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    /// 禁言观众
234    /// tuid: 用户uid
235    /// hour: -1永久 0本场直播
236    /// msg: 禁言理由,一般为禁言的弹幕,选填
237    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        // if let Some(msg) = msg {
265        //     form.push(("msg", msg.to_string()));
266        // }
267
268        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    /// 解除禁言
276    ///
277    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    /// 拉黑观众
299    /// anchor_id:主播uid
300    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    /// 解除拉黑
326    /// anchor_id:主播uid
327    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    /// 添加屏蔽词
355    ///
356    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    /// 删除屏蔽词
382    ///
383    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}