diff --git a/Cargo.lock b/Cargo.lock index c17fb4d..45ef734 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 7a43f14..e87fb9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/formdata.rs b/src/formdata.rs new file mode 100644 index 0000000..ec631d6 --- /dev/null +++ b/src/formdata.rs @@ -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), + 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, + #[builder(default, setter(into))] + #[setters(strip_option)] + /// File name + filename: Option, + #[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 + ?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 + ?Sized, P: AsRef + ?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 + ?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 { + 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(); +} diff --git a/src/main.rs b/src/main.rs index ff22979..2b08e76 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,7 @@ mod exif; mod ext; mod fanbox; mod fanbox_api; +mod formdata; mod i18n; mod list; mod log_cfg; diff --git a/src/push/telegram/botapi_client.rs b/src/push/telegram/botapi_client.rs index 012f112..18a2def 100644 --- a/src/push/telegram/botapi_client.rs +++ b/src/push/telegram/botapi_client.rs @@ -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, + photo: InputFile, + caption: Option<&str>, + parse_mode: Option, + show_caption_above_media: Option, + has_spoiler: Option, + disable_notification: Option, + protect_content: Option, + message_effect_id: Option<&str>, + reply_parameters: Option<&ReplyParameters>, + ) -> Result, 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 + ?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.") + } + } +} diff --git a/src/push/telegram/tg_type.rs b/src/push/telegram/tg_type.rs index 79db9f5..73afd0d 100644 --- a/src/push/telegram/tg_type.rs +++ b/src/push/telegram/tg_type.rs @@ -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, /// 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, /// Optional. Mode for parsing entities in the quote. See formatting options for more details. @@ -166,6 +167,15 @@ pub struct ReplyParameters { quote_position: Option, } +#[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!( diff --git a/src/webclient.rs b/src/webclient.rs index db5237e..e429ef4 100644 --- a/src/webclient.rs +++ b/src/webclient.rs @@ -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( + &self, + url: U, + headers: H, + form: FormData, + ) -> Option { + 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 seconds.") + .replace("", format!("{}", t.as_secs_f64()).as_str()) + .as_str() + ); + tokio::time::sleep(t).await; + } + } + log::info!( + "{}", + gettext("Retry times now.") + .replace("", format!("{}", count).as_str()) + .as_str() + ); + } + None + } + pub async fn _apost2( &self, url: U, @@ -470,6 +512,51 @@ impl WebClient { } self.handle_req_middlewares(r.build()?) } + + pub async fn _apost_multipart2( + &self, + url: U, + headers: H, + form: Form, + ) -> Option { + 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( + &self, + url: U, + headers: H, + form: Form, + ) -> Result { + 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 {