Skip to main content

bpi_rs/dynamic/
publish.rs

1// --- 图片上传 API 结构体 ---
2
3use crate::BilibiliRequest;
4use crate::BpiError;
5use crate::BpiResult;
6use crate::dynamic::DynamicClient;
7use reqwest::Body;
8use reqwest::multipart::{Form, Part};
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use std::path::PathBuf;
12use tokio::fs::File;
13use tokio_util::codec::{BytesCodec, FramedRead};
14
15const UPLOAD_PIC_ENDPOINT: &str = "https://api.bilibili.com/x/dynamic/feed/draw/upload_bfs";
16const CREATE_TEXT_ENDPOINT: &str = "https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/create";
17const CREATE_COMPLEX_ENDPOINT: &str = "https://api.bilibili.com/x/dynamic/feed/create/dyn";
18
19/// 图片上传响应数据
20
21#[derive(Debug, Clone, Deserialize, Serialize)]
22pub struct UploadPicData {
23    /// 已上传图片 URL
24    pub image_url: String,
25    /// 已上传图片宽度
26    pub image_width: u64,
27    /// 已上传图片高度
28    pub image_height: u64,
29    /// 已上传图片大小k
30    pub img_size: f64,
31}
32
33// --- 创建投票 API 结构体 ---
34
35/// 创建投票响应数据
36#[derive(Debug, Clone, Deserialize, Serialize)]
37pub struct CreateVoteData {
38    /// 投票 ID
39    pub vote_id: u64,
40}
41
42// --- 发布纯文本动态 API 结构体 ---
43
44/// 纯文本动态响应数据
45#[derive(Debug, Clone, Deserialize, Serialize)]
46pub struct CreateDynamicData {
47    /// 动态 ID
48    pub dynamic_id: u64,
49    /// 动态 ID 字符串格式
50    pub dynamic_id_str: String,
51    // ... 其他字段
52}
53
54// 复杂动态
55
56/// 动态内容组件
57#[derive(Debug, Clone, Deserialize, Serialize)]
58pub struct DynamicContentItem {
59    /// 组件类型 ID,例如 @用户
60    #[serde(rename = "type")]
61    pub type_num: u8,
62    /// 组件的内容 ID,例如用户的 mid
63    pub biz_id: Option<String>,
64    /// 文本内容
65    pub raw_text: String,
66}
67
68/// 动态图片
69#[derive(Debug, Clone, Deserialize, Serialize)]
70pub struct DynamicPic {
71    /// 图片 URL
72    pub img_src: String,
73    /// 图片高度
74    pub img_height: u64,
75    /// 图片宽度
76    pub img_width: u64,
77    /// 图片大小 (KB)
78    pub img_size: f64,
79}
80
81/// 动态话题
82#[derive(Debug, Clone, Deserialize, Serialize)]
83pub struct DynamicTopic {
84    /// 话题 ID
85    pub id: u64,
86    /// 话题名
87    pub name: String,
88    /// 来源,非必要
89    pub from_source: Option<String>,
90    /// 来源话题 ID,非必要
91    pub from_topic_id: Option<u64>,
92}
93
94/// 动态互动设置
95#[derive(Debug, Clone, Deserialize, Serialize)]
96pub struct DynamicOption {
97    /// 开启精选评论
98    pub up_choose_comment: Option<u8>,
99    /// 关闭评论
100    pub close_comment: Option<u8>,
101}
102
103/// 复杂动态请求体
104#[derive(Debug, Clone, Deserialize, Serialize)]
105pub struct DynamicRequest {
106    /// 特殊卡片,非必要
107    pub attach_card: Option<serde_json::Value>,
108    /// 动态内容
109    pub content: DynamicContent,
110    /// 元信息,非必要
111    pub meta: Option<serde_json::Value>,
112    /// 动态类型
113    pub scene: u8,
114    /// 携带的图片
115    pub pics: Option<Vec<DynamicPic>>,
116    /// 话题
117    pub topic: Option<DynamicTopic>,
118    /// 互动设置
119    pub option: Option<DynamicOption>,
120}
121
122/// 动态内容
123#[derive(Debug, Clone, Deserialize, Serialize)]
124pub struct DynamicContent {
125    pub contents: Vec<DynamicContentItem>,
126}
127
128/// 复杂动态响应数据
129#[derive(Debug, Clone, Deserialize, Serialize)]
130pub struct CreateComplexDynamicData {
131    pub dyn_id: u64,
132    pub dyn_id_str: String,
133    pub dyn_type: u8,
134}
135
136/// Parameters for uploading a dynamic image.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct DynamicUploadPicParams {
139    file_path: PathBuf,
140    category: String,
141}
142
143impl DynamicUploadPicParams {
144    pub fn new(file_path: impl Into<PathBuf>) -> Self {
145        Self {
146            file_path: file_path.into(),
147            category: "daily".to_string(),
148        }
149    }
150
151    pub fn category(mut self, category: impl Into<String>) -> BpiResult<Self> {
152        self.category = normalize_non_blank("category", category.into())?;
153        Ok(self)
154    }
155}
156
157/// Parameters for publishing a plain-text dynamic.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct DynamicTextCreateParams {
160    content: String,
161}
162
163impl DynamicTextCreateParams {
164    pub fn new(content: impl Into<String>) -> BpiResult<Self> {
165        Ok(Self {
166            content: normalize_non_blank("content", content.into())?,
167        })
168    }
169}
170
171/// Parameters for publishing a complex dynamic.
172#[derive(Debug, Clone)]
173pub struct DynamicComplexCreateParams {
174    scene: u8,
175    contents: Vec<DynamicContentItem>,
176    pics: Option<Vec<DynamicPic>>,
177    topic: Option<DynamicTopic>,
178}
179
180impl DynamicComplexCreateParams {
181    pub fn new(scene: u8, contents: Vec<DynamicContentItem>) -> BpiResult<Self> {
182        if !matches!(scene, 1 | 2 | 4) {
183            return Err(BpiError::invalid_parameter(
184                "scene",
185                "value must be 1, 2, or 4",
186            ));
187        }
188        if contents.is_empty() {
189            return Err(BpiError::invalid_parameter(
190                "contents",
191                "at least one content item is required",
192            ));
193        }
194
195        Ok(Self {
196            scene,
197            contents,
198            pics: None,
199            topic: None,
200        })
201    }
202
203    pub fn pics(mut self, pics: Vec<DynamicPic>) -> BpiResult<Self> {
204        if pics.is_empty() {
205            return Err(BpiError::invalid_parameter(
206                "pics",
207                "at least one picture is required",
208            ));
209        }
210        self.pics = Some(pics);
211        Ok(self)
212    }
213
214    pub fn topic(mut self, topic: DynamicTopic) -> Self {
215        self.topic = Some(topic);
216        self
217    }
218
219    fn request_body(self) -> serde_json::Value {
220        let dyn_req = DynamicRequest {
221            attach_card: None,
222            content: DynamicContent {
223                contents: self.contents,
224            },
225            meta: Some(json!({
226                "app_meta": {
227                    "from": "create.dynamic.web",
228                    "mobi_app": "web"
229                }
230            })),
231            scene: self.scene,
232            pics: self.pics,
233            topic: self.topic,
234            option: None,
235        };
236
237        json!({ "dyn_req": dyn_req })
238    }
239}
240
241impl<'a> DynamicClient<'a> {
242    /// Uploads a dynamic image and returns the canonical payload result.
243    pub async fn upload_pic(&self, params: DynamicUploadPicParams) -> BpiResult<UploadPicData> {
244        let csrf = self.client.csrf()?;
245
246        let file = File::open(&params.file_path)
247            .await
248            .map_err(|_| BpiError::parse("打开文件失败"))?;
249        let stream = FramedRead::new(file, BytesCodec::new());
250        let body = Body::wrap_stream(stream);
251
252        let file_name = params.file_path.file_name().ok_or_else(|| {
253            BpiError::parse("Invalid file path, cannot get file name".to_string())
254        })?;
255
256        let file_part = Part::stream(body)
257            .file_name(file_name.to_string_lossy().into_owned())
258            .mime_str("image/jpeg")?;
259
260        let form = Form::new()
261            .part("file_up", file_part)
262            .text("csrf", csrf.clone())
263            .text("category", params.category)
264            .text("biz", "new_dyn".to_string());
265
266        self.client
267            .post(UPLOAD_PIC_ENDPOINT)
268            .multipart(form)
269            .send_bpi_payload("dynamic.pic.upload")
270            .await
271    }
272
273    /// Publishes a plain-text dynamic and returns the canonical payload result.
274    pub async fn create_text(
275        &self,
276        params: DynamicTextCreateParams,
277    ) -> BpiResult<CreateDynamicData> {
278        let csrf = self.client.csrf()?;
279        let form = Form::new()
280            .text("dynamic_id", "0")
281            .text("type", "4")
282            .text("rid", "0")
283            .text("content", params.content)
284            .text("csrf", csrf.clone())
285            .text("csrf_token", csrf);
286
287        self.client
288            .post(CREATE_TEXT_ENDPOINT)
289            .multipart(form)
290            .send_bpi_payload("dynamic.text.create")
291            .await
292    }
293
294    /// Publishes a complex dynamic and returns the canonical payload result.
295    pub async fn create_complex(
296        &self,
297        params: DynamicComplexCreateParams,
298    ) -> BpiResult<CreateComplexDynamicData> {
299        let csrf = self.client.csrf()?;
300        let request_body = params.request_body();
301
302        self.client
303            .post(CREATE_COMPLEX_ENDPOINT)
304            .header("Content-Type", "application/json")
305            .query(&[("csrf", csrf)])
306            .body(request_body.to_string())
307            .send_bpi_payload("dynamic.complex.create")
308            .await
309    }
310}
311
312fn normalize_non_blank(field: &'static str, value: String) -> BpiResult<String> {
313    let value = value.trim().to_string();
314    if value.is_empty() {
315        return Err(BpiError::invalid_parameter(field, "value cannot be blank"));
316    }
317
318    Ok(value)
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn dynamic_text_create_params_rejects_blank_content() {
327        let err = DynamicTextCreateParams::new("  ").unwrap_err();
328
329        assert!(matches!(
330            err,
331            BpiError::InvalidParameter {
332                field: "content",
333                ..
334            }
335        ));
336    }
337}