diff --git a/Cargo.lock b/Cargo.lock index 23654cc..c17fb4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -358,6 +358,41 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.68", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.68", +] + [[package]] name = "dateparser" version = "0.2.1" @@ -381,6 +416,37 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_builder" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd33f37ee6a119146a1781d3356a7c26028f83d779b2e04ecd45fdc75c76877b" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7431fa049613920234f22c47fdc33e6cf3ee83067091ea4277a3f8c4587aae38" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.68", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4abae7035bf79b9877b779505d8cf3749285b80c43941eda66604841889451dc" +dependencies = [ + "derive_builder_core", + "syn 2.0.68", +] + [[package]] name = "derive_more" version = "0.99.18" @@ -867,6 +933,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.5.0" @@ -1534,6 +1606,7 @@ dependencies = [ "chrono", "cmake", "dateparser", + "derive_builder", "derive_more", "fancy-regex", "flagset", @@ -1735,6 +1808,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "once_cell", "percent-encoding", @@ -1998,6 +2072,12 @@ dependencies = [ "quote", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "1.0.109" diff --git a/Cargo.toml b/Cargo.toml index 7f36b7b..7a43f14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ c_fixed_string = { version = "0.2", optional = true } cfg-if = "1" chrono = { version = "0.4", features = ["serde"] } dateparser = "0.2.0" +derive_builder = "0.20" derive_more = "0.99" fancy-regex = "0.11" flagset = { version = "0.4", optional = true } @@ -43,11 +44,11 @@ percent-encoding = "*" proc_macros = { path = "proc_macros" } rand = { version = "0", optional = true } regex = "1" -reqwest = { version = "0.11", features = ["brotli", "deflate", "gzip", "socks", "stream"] } +reqwest = { version = "0.11", features = ["brotli", "deflate", "gzip", "multipart", "socks", "stream"] } rusqlite = { version = "0.29", features = ["bundled", "chrono"], optional = true } RustyXML = "0.3" serde = "1" -serde_json = { version = "1", optional = true } +serde_json = "1" serde_urlencoded = { version = "*", optional = true } tokio = { version = "1.27", features = ["rt", "macros", "rt-multi-thread", "time"] } url = "2.3" @@ -66,7 +67,7 @@ db_all = ["db", "db_sqlite"] db_sqlite = ["rusqlite"] docker = [] exif = ["bindgen", "c_fixed_string", "cmake", "link-cplusplus", "utf16string"] -server = ["async-trait", "base64", "db", "hex", "hyper", "multipart", "openssl", "serde_json", "rand", "serde_urlencoded"] +server = ["async-trait", "base64", "db", "hex", "hyper", "multipart", "openssl", "rand", "serde_urlencoded"] ugoira = ["avdict", "bindgen", "cmake", "link-cplusplus"] [patch.crates-io] diff --git a/src/db/sqlite/error.rs b/src/db/sqlite/error.rs index 6db0ee5..b62753f 100644 --- a/src/db/sqlite/error.rs +++ b/src/db/sqlite/error.rs @@ -3,6 +3,5 @@ pub enum SqliteError { DbError(rusqlite::Error), DatabaseVersionTooNew, UserNameAlreadyExists, - #[cfg(feature = "serde_json")] SerdeError(serde_json::Error), } diff --git a/src/error.rs b/src/error.rs index 97b134c..823bf4f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -29,7 +29,6 @@ pub enum PixivDownloaderError { ParseIntError(std::num::ParseIntError), ReqwestError(reqwest::Error), PixivAppError(crate::pixivapp::error::PixivAppError), - #[cfg(feature = "serde_json")] SerdeJsonError(serde_json::Error), #[cfg(feature = "serde_urlencoded")] SerdeUrlencodedError(serde_urlencoded::ser::Error), diff --git a/src/push/telegram/botapi_client.rs b/src/push/telegram/botapi_client.rs new file mode 100644 index 0000000..012f112 --- /dev/null +++ b/src/push/telegram/botapi_client.rs @@ -0,0 +1,176 @@ +use super::tg_type::*; +use crate::webclient::WebClient; +use derive_builder::Builder; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +fn default_base() -> String { + String::from("https://api.telegram.org") +} + +#[derive(Builder, Clone, Debug, Deserialize, Serialize)] +/// Bot API Client Config +pub struct BotapiClientConfig { + #[serde(default = "default_base")] + #[builder(default = "String::from(\"https://api.telegram.org\")")] + /// Bot API Server. Default: https://api.telegram.org + pub base: String, + /// Auth token + pub token: String, +} + +pub struct BotapiClient { + cfg: BotapiClientConfig, + client: WebClient, +} + +#[derive(Debug, derive_more::Display, derive_more::From)] +pub enum BotapiClientError { + SerdeJson(serde_json::Error), + Str(String), +} + +impl From<&str> for BotapiClientError { + fn from(value: &str) -> Self { + Self::Str(value.to_owned()) + } +} + +impl BotapiClient { + pub fn new(cfg: &BotapiClientConfig) -> Self { + Self { + cfg: cfg.clone(), + client: WebClient::default(), + } + } + + pub async fn send_message + ?Sized>( + &self, + chat_id: &ChatId, + message_thread_id: Option, + text: &T, + parse_mode: Option, + link_preview_options: Option<&LinkPreviewOptions>, + disable_notification: Option, + protect_content: Option, + message_effect_id: Option<&str>, + reply_parameters: Option<&ReplyParameters>, + ) -> Result, BotapiClientError> { + let mut params = HashMap::new(); + params.insert("chat_id", chat_id.to_string()); + match message_thread_id { + Some(m) => { + params.insert("message_thread_id", m.to_string()); + } + None => {} + } + params.insert("text", text.as_ref().to_owned()); + match parse_mode { + Some(m) => { + params.insert("parse_mode", m.as_ref().to_owned()); + } + None => {} + } + match link_preview_options { + Some(m) => { + params.insert("link_preview_options", serde_json::to_string(m)?); + } + None => {} + } + match disable_notification { + Some(b) => { + params.insert("disable_notification", b.to_string()); + } + None => {} + } + match protect_content { + Some(b) => { + params.insert("protect_content", b.to_string()); + } + None => {} + } + match message_effect_id { + Some(b) => { + params.insert("message_effect_id", b.to_owned()); + } + None => {} + } + match reply_parameters { + Some(b) => { + params.insert("reply_parameters", serde_json::to_string(b)?); + } + None => {} + } + let re = self + .client + .post( + format!("{}/bot{}/sendMessage", self.cfg.base, self.cfg.token), + None, + Some(params), + ) + .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))?, + } + } +} + +#[proc_macros::async_timeout_test(120s)] +#[tokio::test(flavor = "multi_thread")] +async fn test_telegram_botapi_sendmessage() { + 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 data = client + .send_message( + &cid, + None, + "Hello world.", + None, + None, + None, + None, + None, + None, + ) + .await + .unwrap() + .unwrap(); + let r = ReplyParametersBuilder::default() + .message_id(data.message_id) + .build() + .unwrap(); + client + .send_message( + &cid, + data.message_thread_id, + "Reply message", + None, + None, + None, + None, + None, + Some(&r), + ) + .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/mod.rs b/src/push/telegram/mod.rs index ad7f153..bbb1a1c 100644 --- a/src/push/telegram/mod.rs +++ b/src/push/telegram/mod.rs @@ -1,2 +1,6 @@ +/// Telegram Bot API Client +pub mod botapi_client; /// Split long messages into multiple messages if needed (Only supports HTML messages) pub mod text; +/// Telegram params type +pub mod tg_type; diff --git a/src/push/telegram/tg_type.rs b/src/push/telegram/tg_type.rs new file mode 100644 index 0000000..79db9f5 --- /dev/null +++ b/src/push/telegram/tg_type.rs @@ -0,0 +1,183 @@ +use derive_builder::Builder; +use derive_more::From; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +#[derive(Clone, Debug, PartialEq, PartialOrd, From, Deserialize, Serialize)] +#[serde(untagged)] +/// Unique identifier for the target chat or username of the target channel +pub enum ChatId { + /// Unique id for chat + Id(i64), + /// Username of the target channel, `@channelusername` + Username(String), +} + +impl FromStr for ChatId { + type Err = String; + fn from_str(s: &str) -> Result { + match s.parse::() { + Ok(i) => Ok(ChatId::Id(i)), + Err(_) => { + if s.starts_with("@") { + Ok(ChatId::Username(s.to_owned())) + } else { + Err(String::from("Failed to parse chat id.")) + } + } + } + } +} + +impl ToString for ChatId { + fn to_string(&self) -> String { + match self { + ChatId::Id(i) => format!("{}", i), + ChatId::Username(s) => s.to_owned(), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Deserialize, Serialize)] +/// Formatting options +/// See https://core.telegram.org/bots/api#formatting-options +pub enum ParseMode { + /// https://core.telegram.org/bots/api#markdownv2-style + MarkdownV2, + /// https://core.telegram.org/bots/api#html-style + HTML, + /// https://core.telegram.org/bots/api#markdown-style + Markdown, +} + +impl AsRef for ParseMode { + fn as_ref(&self) -> &str { + match self { + ParseMode::HTML => "HTML", + ParseMode::Markdown => "Markdown", + ParseMode::MarkdownV2 => "MarkdownV2", + } + } +} + +#[derive(Builder, Clone, Debug, Default, PartialEq, PartialOrd, Deserialize, Serialize)] +#[builder(setter(strip_option))] +#[builder(default)] +/// Describes the options used for link preview generation. +/// See https://core.telegram.org/bots/api#linkpreviewoptions +pub struct LinkPreviewOptions { + /// Optional. True, if the link preview is disabled + #[serde(skip_serializing_if = "Option::is_none")] + is_disabled: Option, + /// Optional. URL to use for the link preview. + /// If empty, then the first URL found in the message text will be used + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + /// Optional. True, if the media in the link preview is supposed to be shrunk; + /// ignored if the URL isn't explicitly specified or media size change isn't supported for the preview + #[serde(skip_serializing_if = "Option::is_none")] + prefer_small_media: Option, + /// Optional. True, if the media in the link preview is supposed to be enlarged; + /// ignored if the URL isn't explicitly specified or media size change isn't supported for the preview + #[serde(skip_serializing_if = "Option::is_none")] + prefer_large_media: Option, + /// Optional. True, if the link preview must be shown above the message text; + /// otherwise, the link preview will be shown below the message text + #[serde(skip_serializing_if = "Option::is_none")] + show_above_text: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum BotApiResult +where + T: Clone + Serialize, +{ + Ok { + ok: bool, + result: T, + }, + Failed { + ok: bool, + error_code: i64, + description: String, + }, +} + +impl BotApiResult +where + T: Clone + Serialize, +{ + pub fn unwrap(self) -> T { + match self { + Self::Ok { result, .. } => result, + Self::Failed { + description, + error_code, + .. + } => panic!("{} ({})", description, error_code), + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +/// This object represents a message. +/// https://core.telegram.org/bots/api#message +pub struct Message { + /// Unique message identifier inside this chat + pub message_id: i64, + /// Optional. Unique identifier of a message thread to which the message belongs; + /// for supergroups only + pub message_thread_id: Option, +} + +#[derive(Builder, Clone, Debug, Deserialize, Serialize)] +pub struct ReplyParameters { + /// Identifier of the message that will be replied to in the current chat, + /// or in the chat chat_id if it is specified + message_id: i64, + /// 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)] + #[serde(skip_serializing_if = "Option::is_none")] + chat_id: Option, + /// Optional. Pass True if the message should be sent even if the specified message + /// to be replied to is not found. Always False for replies in another chat or forum topic. + /// Always True for messages sent on behalf of a business account. + #[builder(setter(strip_option))] + #[builder(default)] + #[serde(skip_serializing_if = "Option::is_none")] + allow_sending_without_reply: Option, + /// Optional. Quoted part of the message to be replied to; 0-1024 characters after entities parsing. + /// 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)] + #[serde(skip_serializing_if = "Option::is_none")] + quote: Option, + /// Optional. Mode for parsing entities in the quote. See formatting options for more details. + #[builder(default)] + #[serde(skip_serializing_if = "Option::is_none")] + quote_parse_mode: Option, + /// Optional. Position of the quote in the original message in UTF-16 code units + #[builder(default)] + #[serde(skip_serializing_if = "Option::is_none")] + quote_position: Option, +} + +#[test] +fn test_chat_id() { + assert_eq!( + serde_json::from_str::("32").unwrap(), + ChatId::Id(32) + ); +} + +#[test] +fn test_parse_mode() { + assert_eq!( + serde_json::from_str::("\"MarkdownV2\"").unwrap(), + ParseMode::MarkdownV2 + ); +}