Add send_photo

This commit is contained in:
2024-09-20 06:22:17 +00:00
committed by GitHub
parent 995ea625a8
commit cd8b2a779c
7 changed files with 393 additions and 2 deletions

13
Cargo.lock generated
View File

@@ -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",

View File

@@ -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
View 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();
}

View File

@@ -32,6 +32,7 @@ mod exif;
mod ext;
mod fanbox;
mod fanbox_api;
mod formdata;
mod i18n;
mod list;
mod log_cfg;

View File

@@ -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.")
}
}
}

View File

@@ -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!(

View File

@@ -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 {