Skip to main content

bpi_rs/comment/
action.rs

1// 评论区相关操作 API
2//
3// [参考文档](https://github.com/Yuelioi/bilibili-API-collect/tree/cfc5fddcc8a94b74d91970bb5b4eaeb349addc47/docs/comment/action)
4
5use crate::BilibiliRequest;
6use crate::BpiError;
7use crate::comment::CommentClient;
8use crate::response::BpiResult;
9use serde::{Deserialize, Serialize};
10
11const ADD_ENDPOINT: &str = "https://api.bilibili.com/x/v2/reply/add";
12const LIKE_ENDPOINT: &str = "https://api.bilibili.com/x/v2/reply/action";
13const DISLIKE_ENDPOINT: &str = "https://api.bilibili.com/x/v2/reply/hate";
14const DELETE_ENDPOINT: &str = "https://api.bilibili.com/x/v2/reply/del";
15const TOP_ENDPOINT: &str = "https://api.bilibili.com/x/v2/reply/top";
16const REPORT_ENDPOINT: &str = "https://api.bilibili.com/x/v2/reply/report";
17
18/// 评论区类型枚举(部分示例,需按需求扩展)
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "lowercase")]
22pub enum CommentType {
23    Video = 1,    // 视频
24    Article = 12, // 专栏
25    Dynamic = 17, // 动态
26    Unknown = 0,
27}
28
29/// 举报原因枚举
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31pub enum ReportReason {
32    Other = 0,
33    Ad = 1,
34    Porn = 2,
35    Spam = 3,
36    Flame = 4,
37    Spoiler = 5,
38    Politics = 6,
39    Abuse = 7,
40    Irrelevant = 8,
41    Illegal = 9,
42    Vulgar = 10,
43    Phishing = 11,
44    Scam = 12,
45    Rumor = 13,
46    Incitement = 14,
47    Privacy = 15,
48    FloorSnatching = 16,
49    HarmfulToYouth = 17,
50}
51
52/// 评论成功返回数据
53#[derive(Debug, Serialize, Clone, Deserialize)]
54pub struct CommentData {
55    pub rpid: u64,
56    pub rpid_str: String,
57    pub root: u64,
58    pub root_str: String,
59    pub parent: u64,
60    pub parent_str: String,
61    pub dialog: u64,
62    pub dialog_str: String,
63    pub success_toast: Option<String>,
64}
65
66/// Parameters for publishing a comment.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct CommentAddParams {
69    r#type: CommentType,
70    oid: u64,
71    message: String,
72    root: Option<u64>,
73    parent: Option<u64>,
74    plat: u8,
75}
76
77impl CommentAddParams {
78    pub fn new(r#type: CommentType, oid: u64, message: impl Into<String>) -> BpiResult<Self> {
79        validate_comment_type(r#type)?;
80
81        Ok(Self {
82            r#type,
83            oid: validate_nonzero_u64("oid", oid)?,
84            message: normalize_non_blank("message", message.into())?,
85            root: None,
86            parent: None,
87            plat: 1,
88        })
89    }
90
91    pub fn root(mut self, root: u64) -> BpiResult<Self> {
92        self.root = Some(validate_nonzero_u64("root", root)?);
93        Ok(self)
94    }
95
96    pub fn parent(mut self, parent: u64) -> BpiResult<Self> {
97        self.parent = Some(validate_nonzero_u64("parent", parent)?);
98        Ok(self)
99    }
100
101    pub(crate) fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
102        let mut pairs = vec![
103            ("type", comment_type_value(self.r#type).to_string()),
104            ("oid", self.oid.to_string()),
105            ("message", self.message.clone()),
106            ("plat", self.plat.to_string()),
107            ("csrf", csrf.to_string()),
108        ];
109
110        if let Some(root) = self.root {
111            pairs.push(("root", root.to_string()));
112        }
113        if let Some(parent) = self.parent {
114            pairs.push(("parent", parent.to_string()));
115        }
116
117        pairs
118    }
119}
120
121/// Parameters for binary comment actions such as like, dislike, and top.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct CommentActionParams {
124    r#type: CommentType,
125    oid: u64,
126    rpid: u64,
127    action: u8,
128}
129
130impl CommentActionParams {
131    pub fn new(r#type: CommentType, oid: u64, rpid: u64, action: u8) -> BpiResult<Self> {
132        validate_comment_type(r#type)?;
133
134        Ok(Self {
135            r#type,
136            oid: validate_nonzero_u64("oid", oid)?,
137            rpid: validate_nonzero_u64("rpid", rpid)?,
138            action: validate_binary_action(action)?,
139        })
140    }
141
142    pub(crate) fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
143        vec![
144            ("type", comment_type_value(self.r#type).to_string()),
145            ("oid", self.oid.to_string()),
146            ("rpid", self.rpid.to_string()),
147            ("action", self.action.to_string()),
148            ("csrf", csrf.to_string()),
149        ]
150    }
151}
152
153/// Parameters for deleting a comment.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub struct CommentDeleteParams {
156    r#type: CommentType,
157    oid: u64,
158    rpid: u64,
159}
160
161impl CommentDeleteParams {
162    pub fn new(r#type: CommentType, oid: u64, rpid: u64) -> BpiResult<Self> {
163        validate_comment_type(r#type)?;
164
165        Ok(Self {
166            r#type,
167            oid: validate_nonzero_u64("oid", oid)?,
168            rpid: validate_nonzero_u64("rpid", rpid)?,
169        })
170    }
171
172    pub(crate) fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
173        vec![
174            ("type", comment_type_value(self.r#type).to_string()),
175            ("oid", self.oid.to_string()),
176            ("rpid", self.rpid.to_string()),
177            ("csrf", csrf.to_string()),
178        ]
179    }
180}
181
182/// Parameters for reporting a comment.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct CommentReportParams {
185    r#type: CommentType,
186    oid: u64,
187    rpid: u64,
188    reason: ReportReason,
189    content: Option<String>,
190}
191
192impl CommentReportParams {
193    pub fn new(r#type: CommentType, oid: u64, rpid: u64, reason: ReportReason) -> BpiResult<Self> {
194        validate_comment_type(r#type)?;
195
196        Ok(Self {
197            r#type,
198            oid: validate_nonzero_u64("oid", oid)?,
199            rpid: validate_nonzero_u64("rpid", rpid)?,
200            reason,
201            content: None,
202        })
203    }
204
205    pub fn content(mut self, content: impl Into<String>) -> BpiResult<Self> {
206        self.content = Some(normalize_non_blank("content", content.into())?);
207        Ok(self)
208    }
209
210    pub(crate) fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
211        let mut pairs = vec![
212            ("type", comment_type_value(self.r#type).to_string()),
213            ("oid", self.oid.to_string()),
214            ("rpid", self.rpid.to_string()),
215            ("reason", report_reason_value(self.reason).to_string()),
216            ("csrf", csrf.to_string()),
217        ];
218
219        if let Some(content) = &self.content {
220            pairs.push(("content", content.clone()));
221        }
222
223        pairs
224    }
225}
226
227impl<'a> CommentClient<'a> {
228    /// Publishes a comment and returns the canonical payload result.
229    pub async fn add(&self, params: CommentAddParams) -> BpiResult<CommentData> {
230        let csrf = self.client.csrf()?;
231        self.client
232            .post(ADD_ENDPOINT)
233            .form(&params.form_pairs(&csrf))
234            .send_bpi_payload("comment.action.add")
235            .await
236    }
237
238    /// Likes or unlikes a comment and returns the canonical payload result.
239    pub async fn like(&self, params: CommentActionParams) -> BpiResult<Option<serde_json::Value>> {
240        let csrf = self.client.csrf()?;
241        self.client
242            .post(LIKE_ENDPOINT)
243            .form(&params.form_pairs(&csrf))
244            .send_bpi_optional_payload("comment.action.like")
245            .await
246    }
247
248    /// Dislikes or undislikes a comment and returns the canonical payload result.
249    pub async fn dislike(
250        &self,
251        params: CommentActionParams,
252    ) -> BpiResult<Option<serde_json::Value>> {
253        let csrf = self.client.csrf()?;
254        self.client
255            .post(DISLIKE_ENDPOINT)
256            .form(&params.form_pairs(&csrf))
257            .send_bpi_optional_payload("comment.action.dislike")
258            .await
259    }
260
261    /// Deletes a comment and returns the canonical payload result.
262    pub async fn delete(
263        &self,
264        params: CommentDeleteParams,
265    ) -> BpiResult<Option<serde_json::Value>> {
266        let csrf = self.client.csrf()?;
267        self.client
268            .post(DELETE_ENDPOINT)
269            .form(&params.form_pairs(&csrf))
270            .send_bpi_optional_payload("comment.action.delete")
271            .await
272    }
273
274    /// Tops or untops a comment and returns the canonical payload result.
275    pub async fn top(&self, params: CommentActionParams) -> BpiResult<Option<serde_json::Value>> {
276        let csrf = self.client.csrf()?;
277        self.client
278            .post(TOP_ENDPOINT)
279            .form(&params.form_pairs(&csrf))
280            .send_bpi_optional_payload("comment.action.top")
281            .await
282    }
283
284    /// Reports a comment and returns the canonical payload result.
285    pub async fn report(
286        &self,
287        params: CommentReportParams,
288    ) -> BpiResult<Option<serde_json::Value>> {
289        let csrf = self.client.csrf()?;
290        self.client
291            .post(REPORT_ENDPOINT)
292            .form(&params.form_pairs(&csrf))
293            .send_bpi_optional_payload("comment.action.report")
294            .await
295    }
296}
297
298fn validate_comment_type(value: CommentType) -> BpiResult<()> {
299    if value == CommentType::Unknown {
300        return Err(BpiError::invalid_parameter(
301            "type",
302            "comment type must be known",
303        ));
304    }
305
306    Ok(())
307}
308
309fn comment_type_value(value: CommentType) -> u32 {
310    value as u32
311}
312
313fn report_reason_value(value: ReportReason) -> u32 {
314    value as u32
315}
316
317fn validate_nonzero_u64(field: &'static str, value: u64) -> BpiResult<u64> {
318    if value == 0 {
319        return Err(BpiError::invalid_parameter(field, "value must be non-zero"));
320    }
321
322    Ok(value)
323}
324
325fn validate_binary_action(value: u8) -> BpiResult<u8> {
326    if matches!(value, 0 | 1) {
327        return Ok(value);
328    }
329
330    Err(BpiError::invalid_parameter(
331        "action",
332        "value must be 0 or 1",
333    ))
334}
335
336fn normalize_non_blank(field: &'static str, value: String) -> BpiResult<String> {
337    let value = value.trim().to_string();
338    if value.is_empty() {
339        return Err(BpiError::invalid_parameter(field, "value cannot be blank"));
340    }
341
342    Ok(value)
343}
344
345#[cfg(test)]
346mod tests {
347    use crate::BpiError;
348
349    use super::{CommentActionParams, CommentAddParams, CommentReportParams, CommentType};
350
351    #[test]
352    fn comment_add_params_rejects_blank_message() {
353        let err = CommentAddParams::new(CommentType::Video, 23199, "  ").unwrap_err();
354
355        assert!(matches!(
356            err,
357            BpiError::InvalidParameter {
358                field: "message",
359                ..
360            }
361        ));
362    }
363
364    #[test]
365    fn comment_add_params_serializes_reply_fields() -> Result<(), BpiError> {
366        let params = CommentAddParams::new(CommentType::Video, 23199, "hello")?
367            .root(2554491176)?
368            .parent(2554491177)?;
369
370        assert_eq!(
371            params.form_pairs("csrf-token"),
372            vec![
373                ("type", "1".to_string()),
374                ("oid", "23199".to_string()),
375                ("message", "hello".to_string()),
376                ("plat", "1".to_string()),
377                ("csrf", "csrf-token".to_string()),
378                ("root", "2554491176".to_string()),
379                ("parent", "2554491177".to_string()),
380            ]
381        );
382        Ok(())
383    }
384
385    #[test]
386    fn comment_action_params_rejects_invalid_action() {
387        let err = CommentActionParams::new(CommentType::Video, 23199, 2554491176, 2).unwrap_err();
388
389        assert!(matches!(
390            err,
391            BpiError::InvalidParameter {
392                field: "action",
393                ..
394            }
395        ));
396    }
397
398    #[test]
399    fn comment_report_params_rejects_blank_content() -> Result<(), BpiError> {
400        let err = CommentReportParams::new(
401            CommentType::Video,
402            23199,
403            2554491176,
404            super::ReportReason::Other,
405        )?
406        .content(" ")
407        .unwrap_err();
408
409        assert!(matches!(
410            err,
411            BpiError::InvalidParameter {
412                field: "content",
413                ..
414            }
415        ));
416        Ok(())
417    }
418}