mirror of
https://github.com/lifegpc/pixiv_downloader.git
synced 2026-06-06 05:49:01 +08:00
Add send_photo
This commit is contained in:
13
Cargo.lock
generated
13
Cargo.lock
generated
@@ -460,6 +460,18 @@ dependencies = [
|
||||
"syn 2.0.68",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_setters"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e8ef033054e131169b8f0f9a7af8f5533a9436fadf3c500ed547f730f07090d"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.68",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "destructure_traitobject"
|
||||
version = "0.2.0"
|
||||
@@ -1608,6 +1620,7 @@ dependencies = [
|
||||
"dateparser",
|
||||
"derive_builder",
|
||||
"derive_more",
|
||||
"derive_setters",
|
||||
"fancy-regex",
|
||||
"flagset",
|
||||
"futures-util",
|
||||
|
||||
@@ -16,6 +16,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
dateparser = "0.2.0"
|
||||
derive_builder = "0.20"
|
||||
derive_more = "0.99"
|
||||
derive_setters = "0.1"
|
||||
fancy-regex = "0.11"
|
||||
flagset = { version = "0.4", optional = true }
|
||||
futures-util = "0.3"
|
||||
|
||||
135
src/formdata.rs
Normal file
135
src/formdata.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use derive_builder::Builder;
|
||||
use derive_setters::Setters;
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, derive_more::From)]
|
||||
pub enum FormDataBody {
|
||||
Data(Vec<u8>),
|
||||
File(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Builder, Debug, Setters)]
|
||||
#[builder(pattern = "owned", setter(strip_option))]
|
||||
#[setters(borrow_self, into)]
|
||||
/// Part of form
|
||||
pub struct FormDataPart {
|
||||
#[builder(default, setter(into))]
|
||||
#[setters(strip_option)]
|
||||
/// Mime type
|
||||
mime: Option<String>,
|
||||
#[builder(default, setter(into))]
|
||||
#[setters(strip_option)]
|
||||
/// File name
|
||||
filename: Option<String>,
|
||||
#[builder(default)]
|
||||
#[setters(skip)]
|
||||
/// HTTP headers
|
||||
pub headers: HeaderMap,
|
||||
#[setters(skip)]
|
||||
#[builder(setter(into))]
|
||||
/// Body
|
||||
body: FormDataBody,
|
||||
}
|
||||
|
||||
/// Form
|
||||
pub struct FormData {
|
||||
fields: Vec<(String, FormDataPart)>,
|
||||
}
|
||||
|
||||
/// Error when convert [FormData] to [Form]
|
||||
#[derive(Debug, derive_more::Display, derive_more::From)]
|
||||
pub enum FormDataError {
|
||||
IOError(std::io::Error),
|
||||
Reqwest(reqwest::Error),
|
||||
}
|
||||
|
||||
impl FormData {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn data<'a, K: AsRef<str> + ?Sized, V: AsRef<[u8]> + ?Sized>(
|
||||
&'a mut self,
|
||||
key: &K,
|
||||
value: &V,
|
||||
) -> &'a mut FormDataPart {
|
||||
let part = FormDataPartBuilder::default()
|
||||
.body(FormDataBody::Data(value.as_ref().to_vec()))
|
||||
.build()
|
||||
.unwrap();
|
||||
self.fields.push((key.as_ref().to_owned(), part));
|
||||
&mut self.fields.last_mut().unwrap().1
|
||||
}
|
||||
|
||||
pub fn file<'a, K: AsRef<str> + ?Sized, P: AsRef<Path> + ?Sized>(
|
||||
&'a mut self,
|
||||
key: &K,
|
||||
path: &P,
|
||||
) -> &'a mut FormDataPart {
|
||||
let part = FormDataPartBuilder::default()
|
||||
.body(FormDataBody::File(path.as_ref().to_owned()))
|
||||
.build()
|
||||
.unwrap();
|
||||
self.fields.push((key.as_ref().to_owned(), part));
|
||||
&mut self.fields.last_mut().unwrap().1
|
||||
}
|
||||
|
||||
pub fn part<'a, K: AsRef<str> + ?Sized>(
|
||||
&'a mut self,
|
||||
key: &K,
|
||||
part: FormDataPart,
|
||||
) -> &'a mut FormDataPart {
|
||||
self.fields.push((key.as_ref().to_owned(), part));
|
||||
&mut self.fields.last_mut().unwrap().1
|
||||
}
|
||||
|
||||
pub async fn to_form(&self) -> Result<Form, FormDataError> {
|
||||
let mut f = Form::new();
|
||||
for (k, v) in self.fields.iter() {
|
||||
let mut part = match &v.body {
|
||||
FormDataBody::Data(d) => Part::bytes(d.clone()),
|
||||
FormDataBody::File(f) => Part::bytes(tokio::fs::read(f).await?),
|
||||
};
|
||||
match &v.mime {
|
||||
Some(m) => {
|
||||
part = part.mime_str(m)?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
match &v.filename {
|
||||
Some(f) => {
|
||||
part = part.file_name(f.clone());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
part = part.headers(v.headers.clone());
|
||||
f = f.part(k.clone(), part);
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FormData {
|
||||
fn default() -> Self {
|
||||
Self { fields: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
#[proc_macros::async_timeout_test(120s)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_formdata() {
|
||||
let p = Path::new("./test");
|
||||
if !p.exists() {
|
||||
let re = std::fs::create_dir("./test");
|
||||
assert!(re.is_ok() || p.exists());
|
||||
}
|
||||
std::fs::write("test/formdata.txt", "Good job!").unwrap();
|
||||
let mut f = FormData::new();
|
||||
f.data("test", "test2").filename("test.txt");
|
||||
f.file("test2", "test/formdata.txt")
|
||||
.filename("formdata.txt")
|
||||
.mime("text/plain");
|
||||
f.to_form().await.unwrap();
|
||||
}
|
||||
@@ -32,6 +32,7 @@ mod exif;
|
||||
mod ext;
|
||||
mod fanbox;
|
||||
mod fanbox_api;
|
||||
mod formdata;
|
||||
mod i18n;
|
||||
mod list;
|
||||
mod log_cfg;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::tg_type::*;
|
||||
use crate::formdata::FormData;
|
||||
#[cfg(test)]
|
||||
use crate::formdata::FormDataPartBuilder;
|
||||
use crate::webclient::WebClient;
|
||||
use derive_builder::Builder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -44,6 +47,100 @@ impl BotapiClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_photo(
|
||||
&self,
|
||||
chat_id: &ChatId,
|
||||
message_thread_id: Option<i64>,
|
||||
photo: InputFile,
|
||||
caption: Option<&str>,
|
||||
parse_mode: Option<ParseMode>,
|
||||
show_caption_above_media: Option<bool>,
|
||||
has_spoiler: Option<bool>,
|
||||
disable_notification: Option<bool>,
|
||||
protect_content: Option<bool>,
|
||||
message_effect_id: Option<&str>,
|
||||
reply_parameters: Option<&ReplyParameters>,
|
||||
) -> Result<BotApiResult<Message>, BotapiClientError> {
|
||||
let mut form = FormData::new();
|
||||
form.data("chat_id", &chat_id.to_string());
|
||||
match message_thread_id {
|
||||
Some(m) => {
|
||||
form.data("message_thread_id", &m.to_string());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
match photo {
|
||||
InputFile::URL(u) => {
|
||||
form.data("photo", &u);
|
||||
}
|
||||
InputFile::Content(c) => {
|
||||
form.part("photo", c);
|
||||
}
|
||||
}
|
||||
match caption {
|
||||
Some(c) => {
|
||||
form.data("caption", c);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
match parse_mode {
|
||||
Some(p) => {
|
||||
form.data("parse_mode", p.as_ref());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
match show_caption_above_media {
|
||||
Some(p) => {
|
||||
form.data("show_caption_above_media", &p.to_string());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
match has_spoiler {
|
||||
Some(p) => {
|
||||
form.data("has_spoiler", &p.to_string());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
match disable_notification {
|
||||
Some(d) => {
|
||||
form.data("disable_notification", &d.to_string());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
match protect_content {
|
||||
Some(p) => {
|
||||
form.data("protect_content", &p.to_string());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
match message_effect_id {
|
||||
Some(m) => {
|
||||
form.data("message_effect_id", m);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
match reply_parameters {
|
||||
Some(r) => {
|
||||
form.data("reply_parameters", serde_json::to_string(r)?.as_str());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
let re = self
|
||||
.client
|
||||
.post_multipart(
|
||||
format!("{}/bot{}/sendPhoto", self.cfg.base, self.cfg.token),
|
||||
None,
|
||||
form,
|
||||
)
|
||||
.await
|
||||
.ok_or("Failed to send message.")?;
|
||||
let status = re.status();
|
||||
match re.text().await {
|
||||
Ok(t) => Ok(serde_json::from_str(t.as_str())?),
|
||||
Err(e) => Err(format!("HTTP ERROR {}: {}", status, e))?,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_message<T: AsRef<str> + ?Sized>(
|
||||
&self,
|
||||
chat_id: &ChatId,
|
||||
@@ -174,3 +271,50 @@ async fn test_telegram_botapi_sendmessage() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[proc_macros::async_timeout_test(120s)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_telegram_botapi_sendphoto() {
|
||||
match std::env::var("TGBOT_TOKEN") {
|
||||
Ok(token) => match std::env::var("TGBOT_CHATID") {
|
||||
Ok(c) => {
|
||||
let cfg = BotapiClientConfigBuilder::default()
|
||||
.token(token)
|
||||
.build()
|
||||
.unwrap();
|
||||
let client = BotapiClient::new(&cfg);
|
||||
let cid = ChatId::try_from(c).unwrap();
|
||||
let pb = std::path::PathBuf::from("./testdata/夏のチマメ隊🏖️_91055644_p0.jpg");
|
||||
let c = FormDataPartBuilder::default()
|
||||
.body(pb)
|
||||
.filename("夏のチマメ隊🏖️_91055644_p0.jpg")
|
||||
.mime("image/jpeg")
|
||||
.build()
|
||||
.unwrap();
|
||||
client
|
||||
.send_photo(
|
||||
&cid,
|
||||
None,
|
||||
InputFile::Content(c),
|
||||
Some("test.test.test"),
|
||||
None,
|
||||
None,
|
||||
Some(true),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
Err(_) => {
|
||||
println!("No chat id specified, skip test.")
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
println!("No tg bot token specified, skip test.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::formdata::FormDataPart;
|
||||
use derive_builder::Builder;
|
||||
use derive_more::From;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -139,7 +140,7 @@ pub struct ReplyParameters {
|
||||
/// Optional. If the message to be replied to is from a different chat,
|
||||
/// unique identifier for the chat or username of the channel (in the format `@channelusername`).
|
||||
/// Not supported for messages sent on behalf of a business account.
|
||||
#[builder(default)]
|
||||
#[builder(default, setter(into))]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
chat_id: Option<ChatId>,
|
||||
/// Optional. Pass True if the message should be sent even if the specified message
|
||||
@@ -153,7 +154,7 @@ pub struct ReplyParameters {
|
||||
/// The quote must be an exact substring of the message to be replied to,
|
||||
/// including bold, italic, underline, strikethrough, spoiler, and custom_emoji entities.
|
||||
/// The message will fail to send if the quote isn't found in the original message.
|
||||
#[builder(default)]
|
||||
#[builder(default, setter(into))]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
quote: Option<String>,
|
||||
/// Optional. Mode for parsing entities in the quote. See formatting options for more details.
|
||||
@@ -166,6 +167,15 @@ pub struct ReplyParameters {
|
||||
quote_position: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, derive_more::From)]
|
||||
/// Represents the contents of a file
|
||||
pub enum InputFile {
|
||||
/// URL
|
||||
URL(String),
|
||||
/// File data
|
||||
Content(FormDataPart),
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chat_id() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -4,11 +4,13 @@ use crate::error::PixivDownloaderError;
|
||||
use crate::ext::atomic::AtomicQuick;
|
||||
use crate::ext::json::ToJson;
|
||||
use crate::ext::rw_lock::GetRwLock;
|
||||
use crate::formdata::FormData;
|
||||
use crate::gettext;
|
||||
use crate::list::NonTailList;
|
||||
use crate::opthelper::get_helper;
|
||||
use json::JsonValue;
|
||||
use proc_macros::print_error;
|
||||
use reqwest::multipart::Form;
|
||||
use reqwest::{Client, ClientBuilder, IntoUrl, Request, Response};
|
||||
use serde::ser::Serialize;
|
||||
use std::collections::HashMap;
|
||||
@@ -420,6 +422,46 @@ impl WebClient {
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn post_multipart<U: IntoUrl + Clone, H: ToHeaders + Clone>(
|
||||
&self,
|
||||
url: U,
|
||||
headers: H,
|
||||
form: FormData,
|
||||
) -> Option<Response> {
|
||||
let mut count = 0i64;
|
||||
let retry = self.get_retry();
|
||||
while retry < 0 || count <= retry {
|
||||
let f = print_error!(gettext("Failed to generate form:"), form.to_form().await);
|
||||
let r = self
|
||||
._apost_multipart2(url.clone(), headers.clone(), f)
|
||||
.await;
|
||||
if r.is_some() {
|
||||
return r;
|
||||
}
|
||||
count += 1;
|
||||
if retry < 0 || count <= retry {
|
||||
let t =
|
||||
self.get_retry_interval().as_ref().unwrap()[(count - 1).try_into().unwrap()];
|
||||
if !t.is_zero() {
|
||||
log::info!(
|
||||
"{}",
|
||||
gettext("Retry after <num> seconds.")
|
||||
.replace("<num>", format!("{}", t.as_secs_f64()).as_str())
|
||||
.as_str()
|
||||
);
|
||||
tokio::time::sleep(t).await;
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"{}",
|
||||
gettext("Retry <count> times now.")
|
||||
.replace("<count>", format!("{}", count).as_str())
|
||||
.as_str()
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn _apost2<U: IntoUrl, H: ToHeaders, S: Serialize>(
|
||||
&self,
|
||||
url: U,
|
||||
@@ -470,6 +512,51 @@ impl WebClient {
|
||||
}
|
||||
self.handle_req_middlewares(r.build()?)
|
||||
}
|
||||
|
||||
pub async fn _apost_multipart2<U: IntoUrl, H: ToHeaders>(
|
||||
&self,
|
||||
url: U,
|
||||
headers: H,
|
||||
form: Form,
|
||||
) -> Option<Response> {
|
||||
let r = print_error!(
|
||||
gettext("Failed to generate request:"),
|
||||
self._apost_multipart(url, headers, form)
|
||||
);
|
||||
let r = print_error!(gettext("Error when request:"), self.client.execute(r).await);
|
||||
self.handle_set_cookie(&r);
|
||||
log::debug!(target: "webclient","{}", r.status());
|
||||
Some(r)
|
||||
}
|
||||
|
||||
pub fn _apost_multipart<U: IntoUrl, H: ToHeaders>(
|
||||
&self,
|
||||
url: U,
|
||||
headers: H,
|
||||
form: Form,
|
||||
) -> Result<Request, PixivDownloaderError> {
|
||||
let s = url.as_str();
|
||||
log::debug!(target: "webclient", "POST {}", s);
|
||||
let mut r = self.client.post(s);
|
||||
for (k, v) in self.get_headers().iter() {
|
||||
r = r.header(k, v);
|
||||
log::debug!(target: "webclient", "{}: {}", k, v);
|
||||
}
|
||||
let headers = headers.to_headers();
|
||||
if headers.is_some() {
|
||||
let h = headers.unwrap();
|
||||
for (k, v) in h.iter() {
|
||||
r = r.header(k, v);
|
||||
log::debug!(target: "webclient", "{}: {}", k, v);
|
||||
}
|
||||
}
|
||||
let c = gen_cookie_header(&self, s);
|
||||
if c.len() > 0 {
|
||||
r = r.header("Cookie", c.as_str());
|
||||
}
|
||||
r = r.multipart(form);
|
||||
self.handle_req_middlewares(r.build()?)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WebClient {
|
||||
|
||||
Reference in New Issue
Block a user