1use crate::BilibiliRequest;
2use crate::BpiError;
3use crate::BpiResult;
4use crate::electric::ElectricClient;
5use serde::{Deserialize, Serialize};
6
7const ELEC_MESSAGE_ENDPOINT: &str = "https://api.bilibili.com/x/ugcpay/trade/elec/message";
8const ELEC_REMARK_REPLY_ENDPOINT: &str = "https://member.bilibili.com/x/web/elec/remark/reply";
9
10#[derive(Debug, Clone, Deserialize, Serialize)]
12pub struct ElecRemarkPager {
13 pub current: u64,
15 pub size: u64,
17 pub total: u64,
19}
20
21#[derive(Debug, Clone, Deserialize, Serialize)]
23pub struct ElecRemarkRecord {
24 pub aid: u64,
25 pub bvid: String,
26 pub id: u64,
27 pub mid: u64,
28 pub reply_mid: u64,
29 pub elec_num: u64,
30 pub state: u8,
32 pub msg: String,
34 pub aname: String,
35 pub uname: String,
36 pub avator: String,
37 pub reply_name: String,
38 pub reply_avator: String,
39 pub reply_msg: String,
40 pub ctime: u64,
42 pub reply_time: u64,
43}
44
45#[derive(Debug, Clone, Deserialize, Serialize)]
47pub struct ElecRemarkList {
48 pub list: Vec<ElecRemarkRecord>,
49 pub pager: ElecRemarkPager,
50}
51
52#[derive(Debug, Clone, Deserialize, Serialize)]
54pub struct ElecRemarkDetail {
55 pub aid: u64,
56 pub bvid: String,
57 pub id: u64,
58 pub mid: u64,
60 pub reply_mid: u64,
62 pub elec_num: u64,
63 pub state: u8,
65 pub msg: String,
67 pub aname: String,
68 pub uname: String,
70 pub avator: String,
72 pub reply_name: String,
74 pub reply_avator: String,
76 pub reply_msg: String,
78 pub ctime: u64,
80 pub reply_time: u64,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct ElectricMessageSendParams {
87 order_id: String,
88 message: String,
89}
90
91impl ElectricMessageSendParams {
92 pub fn new(order_id: impl Into<String>, message: impl Into<String>) -> BpiResult<Self> {
93 Ok(Self {
94 order_id: normalize_non_blank("order_id", order_id.into())?,
95 message: normalize_non_blank("message", message.into())?,
96 })
97 }
98
99 fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
100 vec![
101 ("order_id", self.order_id.clone()),
102 ("message", self.message.clone()),
103 ("csrf", csrf.to_string()),
104 ]
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct ElectricRemarkReplyParams {
111 id: u64,
112 msg: String,
113}
114
115impl ElectricRemarkReplyParams {
116 pub fn new(id: u64, msg: impl Into<String>) -> BpiResult<Self> {
117 if id == 0 {
118 return Err(BpiError::invalid_parameter("id", "id must be non-zero"));
119 }
120
121 Ok(Self {
122 id,
123 msg: normalize_non_blank("msg", msg.into())?,
124 })
125 }
126
127 fn form_pairs(&self, csrf: &str) -> Vec<(&'static str, String)> {
128 vec![
129 ("id", self.id.to_string()),
130 ("msg", self.msg.clone()),
131 ("csrf", csrf.to_string()),
132 ]
133 }
134}
135
136impl<'a> ElectricClient<'a> {
137 pub async fn send_message(
139 &self,
140 params: ElectricMessageSendParams,
141 ) -> BpiResult<Option<serde_json::Value>> {
142 let csrf = self.client.csrf()?;
143
144 self.client
145 .post(ELEC_MESSAGE_ENDPOINT)
146 .form(¶ms.form_pairs(&csrf))
147 .send_bpi_optional_payload("electric.message.send")
148 .await
149 }
150
151 pub async fn reply_remark(&self, params: ElectricRemarkReplyParams) -> BpiResult<u64> {
153 let csrf = self.client.csrf()?;
154
155 self.client
156 .post(ELEC_REMARK_REPLY_ENDPOINT)
157 .form(¶ms.form_pairs(&csrf))
158 .send_bpi_payload("electric.remark.reply")
159 .await
160 }
161}
162
163fn normalize_non_blank(field: &'static str, value: String) -> BpiResult<String> {
164 let value = value.trim().to_string();
165 if value.is_empty() {
166 return Err(BpiError::invalid_parameter(field, "value cannot be blank"));
167 }
168
169 Ok(value)
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::probe::contract::HttpMethod;
176 use crate::probe::endpoint_contract::EndpointContract;
177 use crate::probe::flow::ProbeFlow;
178 use crate::{ApiEnvelope, BpiResult};
179
180 fn remark_list_contract() -> BpiResult<EndpointContract> {
181 EndpointContract::from_slice(include_bytes!(
182 "../../tests/contracts/electric/private-read/remark-list/contract.json"
183 ))
184 }
185
186 fn remark_detail_contract() -> BpiResult<EndpointContract> {
187 EndpointContract::from_slice(include_bytes!(
188 "../../tests/contracts/electric/private-read/remark-detail/contract.json"
189 ))
190 }
191
192 fn normal_remark_detail_flow_contract() -> BpiResult<ProbeFlow> {
193 ProbeFlow::from_slice(include_bytes!(
194 "../../tests/contracts/electric/private-read/remark-detail/flow/normal.contract.json"
195 ))
196 }
197
198 fn vip_remark_detail_flow_contract() -> BpiResult<ProbeFlow> {
199 ProbeFlow::from_slice(include_bytes!(
200 "../../tests/contracts/electric/private-read/remark-detail/flow/vip.contract.json"
201 ))
202 }
203
204 #[test]
205 fn electric_remark_list_contract_matches_endpoint_request() -> BpiResult<()> {
206 let contract = remark_list_contract()?;
207
208 assert_eq!(contract.name, "electric.remark_list");
209 assert_eq!(contract.request.method, HttpMethod::Get);
210 assert_eq!(
211 contract.request.url.as_str(),
212 "https://member.bilibili.com/x/web/elec/remark/list"
213 );
214 assert_eq!(
215 contract.request.query.get("pn").map(String::as_str),
216 Some("1")
217 );
218 assert_eq!(
219 contract.request.query.get("ps").map(String::as_str),
220 Some("10")
221 );
222 assert_eq!(contract.cases.len(), 3);
223 assert_eq!(
224 contract.cases[1].response.rust_model.as_deref(),
225 Some("ElecRemarkList")
226 );
227 Ok(())
228 }
229
230 #[test]
231 fn electric_remark_detail_contract_matches_anonymous_endpoint_request() -> BpiResult<()> {
232 let contract = remark_detail_contract()?;
233
234 assert_eq!(contract.name, "electric.remark_detail");
235 assert_eq!(contract.request.method, HttpMethod::Get);
236 assert_eq!(
237 contract.request.url.as_str(),
238 "https://member.bilibili.com/x/web/elec/remark/detail"
239 );
240 assert_eq!(
241 contract.request.query.get("id").map(String::as_str),
242 Some("1")
243 );
244 assert_eq!(contract.cases.len(), 1);
245 assert_eq!(
246 contract.cases[0].response.error.as_deref(),
247 Some("requires_login")
248 );
249 Ok(())
250 }
251
252 #[test]
253 fn electric_remark_detail_flow_contracts_use_list_id_placeholder() -> BpiResult<()> {
254 for flow in [
255 normal_remark_detail_flow_contract()?,
256 vip_remark_detail_flow_contract()?,
257 ] {
258 assert!(matches!(
259 flow.name.as_str(),
260 "electric.remark_detail.normal.flow" | "electric.remark_detail.vip.flow"
261 ));
262 assert_eq!(flow.steps.len(), 2);
263 assert_eq!(flow.steps[0].name, "remark-list");
264 assert_eq!(
265 flow.steps[0].extract.get("remark_id").map(String::as_str),
266 Some("/response/body/data/list/0/id")
267 );
268 assert_eq!(flow.steps[1].name, "remark-detail");
269 assert_eq!(
270 flow.steps[1].contract["request"]["query"]["id"],
271 "${remark_id}"
272 );
273 }
274
275 Ok(())
276 }
277
278 #[test]
279 fn electric_remark_list_response_fixtures_parse_declared_models() -> BpiResult<()> {
280 let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
281 "../../tests/contracts/electric/private-read/remark-list/responses/anonymous.requires_login.json"
282 ))?
283 .ensure_success()
284 .unwrap_err();
285 assert!(err.requires_login());
286
287 let list = ApiEnvelope::<ElecRemarkList>::from_slice(include_bytes!(
288 "../../tests/contracts/electric/private-read/remark-list/responses/authenticated.success.json"
289 ))?
290 .into_payload()?;
291 assert_eq!(list.list.len(), 1);
292 Ok(())
293 }
294
295 #[test]
296 fn electric_remark_detail_response_fixtures_parse_declared_models() -> BpiResult<()> {
297 let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
298 "../../tests/contracts/electric/private-read/remark-detail/responses/anonymous.requires_login.json"
299 ))?
300 .ensure_success()
301 .unwrap_err();
302 assert!(err.requires_login());
303
304 let detail = ApiEnvelope::<ElecRemarkDetail>::from_slice(include_bytes!(
305 "../../tests/contracts/electric/private-read/remark-detail/responses/authenticated.success.json"
306 ))?
307 .into_payload()?;
308 assert_eq!(detail.id, 1);
309 assert_eq!(detail.msg, "<redacted>");
310 Ok(())
311 }
312
313 fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
314 let path = format!(
315 "target/bpi-probe-runs/electric/private-read/{endpoint}/{profile}.response.json"
316 );
317 let bytes = std::fs::read(path).ok()?;
318 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
319 value
320 .get("response")
321 .and_then(|response| response.get("body"))
322 .cloned()
323 }
324
325 fn local_probe_flow_step_body(profile: &str, step: &str) -> Option<serde_json::Value> {
326 let path = format!(
327 "target/bpi-probe-runs/electric/private-read/remark-detail-flow/{profile}.response.json"
328 );
329 let bytes = std::fs::read(path).ok()?;
330 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
331 value
332 .get("steps")?
333 .as_array()?
334 .iter()
335 .find(|entry| entry.get("step").and_then(serde_json::Value::as_str) == Some(step))?
336 .get("result")
337 .and_then(|result| result.get("response"))
338 .and_then(|response| response.get("body"))
339 .cloned()
340 }
341
342 #[test]
343 fn electric_remark_list_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
344 for profile in ["anonymous", "normal", "vip"] {
345 if let Some(body) = local_probe_body("remark-list", profile) {
346 let envelope = serde_json::from_value::<ApiEnvelope<ElecRemarkList>>(body)?;
347 if profile == "anonymous" {
348 let err = envelope.ensure_success().unwrap_err();
349 assert!(err.requires_login());
350 } else {
351 let payload = envelope.into_payload()?;
352 assert!(payload.pager.total >= payload.list.len() as u64);
353 }
354 }
355 }
356 Ok(())
357 }
358
359 #[test]
360 fn electric_remark_detail_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
361 if let Some(body) = local_probe_body("remark-detail", "anonymous") {
362 let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
363 .ensure_success()
364 .unwrap_err();
365 assert!(err.requires_login());
366 }
367
368 for profile in ["normal", "vip"] {
369 if let Some(body) = local_probe_flow_step_body(profile, "remark-detail") {
370 let detail = serde_json::from_value::<ApiEnvelope<ElecRemarkDetail>>(body)?
371 .into_payload()?;
372 assert!(detail.id > 0);
373 }
374 }
375 Ok(())
376 }
377}