1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "lowercase")]
22pub enum CommentType {
23 Video = 1, Article = 12, Dynamic = 17, Unknown = 0,
27}
28
29#[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#[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#[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#[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#[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#[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 pub async fn add(&self, params: CommentAddParams) -> BpiResult<CommentData> {
230 let csrf = self.client.csrf()?;
231 self.client
232 .post(ADD_ENDPOINT)
233 .form(¶ms.form_pairs(&csrf))
234 .send_bpi_payload("comment.action.add")
235 .await
236 }
237
238 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(¶ms.form_pairs(&csrf))
244 .send_bpi_optional_payload("comment.action.like")
245 .await
246 }
247
248 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(¶ms.form_pairs(&csrf))
257 .send_bpi_optional_payload("comment.action.dislike")
258 .await
259 }
260
261 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(¶ms.form_pairs(&csrf))
270 .send_bpi_optional_payload("comment.action.delete")
271 .await
272 }
273
274 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(¶ms.form_pairs(&csrf))
280 .send_bpi_optional_payload("comment.action.top")
281 .await
282 }
283
284 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(¶ms.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}