diff --git a/Cargo.lock b/Cargo.lock index 34cb17e..808e668 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -606,6 +606,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.31" @@ -798,10 +808,12 @@ dependencies = [ "grammers-mtsender", "grammers-session", "grammers-tl-types", + "html5ever", "log", "md5", "mime_guess", "pin-project-lite", + "pulldown-cmark", "tokio", ] @@ -934,6 +946,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + [[package]] name = "http" version = "1.4.0" @@ -1283,6 +1306,34 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1387,6 +1438,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.30.1" @@ -1699,6 +1756,44 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1782,6 +1877,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "prettyplease" version = "0.2.37" @@ -1824,6 +1925,17 @@ dependencies = [ "syn", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + [[package]] name = "quote" version = "1.0.44" @@ -2214,6 +2326,31 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2283,6 +2420,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "tgcli" version = "0.3.6" @@ -2729,6 +2877,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2831,6 +2985,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "which" version = "4.4.2" diff --git a/Cargo.toml b/Cargo.toml index 4c5f61a..4248fc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ name = "tgcli" path = "src/main.rs" [dependencies] -grammers-client = { version = "0.8", features = ["fs"] } +grammers-client = { version = "0.8", features = ["fs", "markdown", "html"] } grammers-session = "0.8" grammers-tl-types = "0.8" grammers-mtsender = "0.8" diff --git a/src/app/send.rs b/src/app/send.rs index 7483e1c..31a4670 100644 --- a/src/app/send.rs +++ b/src/app/send.rs @@ -4,6 +4,7 @@ use crate::store::UpsertMessageParams; use anyhow::{Context, Result}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::Utc; +use grammers_client::parsers::{parse_html_message, parse_markdown_message}; use grammers_client::types::Attribute; use grammers_client::InputMessage; use grammers_session::defs::PeerRef; @@ -13,6 +14,24 @@ use std::path::Path; use std::time::Duration; use tl::enums::SendMessageAction; +/// Parse message text according to parse_mode, returning (text, entities). +/// parse_mode: "markdown", "html", or anything else for plain text. +fn apply_parse_mode(text: &str, parse_mode: &str) -> (String, Option>) { + match parse_mode { + "markdown" => { + let (parsed_text, entities) = parse_markdown_message(text); + let ents = if entities.is_empty() { None } else { Some(entities) }; + (parsed_text, ents) + } + "html" => { + let (parsed_text, entities) = parse_html_message(text); + let ents = if entities.is_empty() { None } else { Some(entities) }; + (parsed_text, ents) + } + _ => (text.to_string(), None), + } +} + /// Result from searching chats via Telegram API. #[derive(Debug, Clone, serde::Serialize)] pub struct SearchChatResult { @@ -37,12 +56,17 @@ fn decode_file_id(file_id: &str) -> Result<(i64, i64, Vec)> { impl App { /// Send a text message to a chat by ID, returns the message ID. - pub async fn send_text(&mut self, chat_id: i64, text: &str) -> Result { + pub async fn send_text(&mut self, chat_id: i64, text: &str, parse_mode: &str) -> Result { let peer_ref = self.resolve_peer_ref(chat_id).await?; + let input_msg = match parse_mode { + "markdown" => InputMessage::new().markdown(text), + "html" => InputMessage::new().html(text), + _ => InputMessage::new().text(text), + }; let msg = self .tg .client - .send_message(peer_ref, InputMessage::new().text(text)) + .send_message(peer_ref, input_msg) .await .context_send(chat_id)?; @@ -78,12 +102,14 @@ impl App { chat_id: i64, text: &str, schedule_time: chrono::DateTime, + parse_mode: &str, ) -> Result { let peer_ref = self.resolve_peer_ref(chat_id).await?; let input_peer: tl::enums::InputPeer = peer_ref.into(); let random_id: i64 = rand::rng().random(); let schedule_date = schedule_time.timestamp() as i32; + let (message_text, entities) = apply_parse_mode(text, parse_mode); let request = tl::functions::messages::SendMessage { no_webpage: true, @@ -96,10 +122,10 @@ impl App { allow_paid_floodskip: false, peer: input_peer, reply_to: None, - message: text.to_string(), + message: message_text, random_id, reply_markup: None, - entities: None, + entities, schedule_date: Some(schedule_date), send_as: None, quick_reply_shortcut: None, @@ -128,11 +154,13 @@ impl App { chat_id: i64, text: &str, reply_to_msg_id: i32, + parse_mode: &str, ) -> Result { let peer_ref = self.resolve_peer_ref(chat_id).await?; let input_peer: tl::enums::InputPeer = peer_ref.into(); let random_id: i64 = rand::rng().random(); + let (message_text, entities) = apply_parse_mode(text, parse_mode); let request = tl::functions::messages::SendMessage { no_webpage: true, @@ -157,10 +185,10 @@ impl App { } .into(), ), - message: text.to_string(), + message: message_text, random_id, reply_markup: None, - entities: None, + entities, schedule_date: None, send_as: None, quick_reply_shortcut: None, @@ -209,11 +237,13 @@ impl App { chat_id: i64, topic_id: i32, text: &str, + parse_mode: &str, ) -> Result { let peer_ref = self.resolve_peer_ref(chat_id).await?; let input_peer: tl::enums::InputPeer = peer_ref.into(); let random_id: i64 = rand::rng().random(); + let (message_text, entities) = apply_parse_mode(text, parse_mode); let request = tl::functions::messages::SendMessage { no_webpage: true, @@ -238,10 +268,10 @@ impl App { } .into(), ), - message: text.to_string(), + message: message_text, random_id, reply_markup: None, - entities: None, + entities, schedule_date: None, send_as: None, quick_reply_shortcut: None, diff --git a/src/cmd/messages.rs b/src/cmd/messages.rs index 32b2a84..18dd186 100644 --- a/src/cmd/messages.rs +++ b/src/cmd/messages.rs @@ -238,7 +238,7 @@ pub enum MessagesCommand { msg_id: i64, /// Output path (default: current directory with auto-detected filename) #[arg(long, short)] - output: Option, + dest: Option, }, } @@ -579,13 +579,13 @@ pub async fn run(cli: &Cli, cmd: &MessagesCommand) -> Result<()> { MessagesCommand::Download { chat, msg_id, - output, + dest, } => { // Download requires network access let app = App::new(cli).await?; let result = app - .download_media(*chat, *msg_id, output.as_deref()) + .download_media(*chat, *msg_id, dest.as_deref()) .await?; if cli.output.is_json() { diff --git a/src/cmd/send.rs b/src/cmd/send.rs index 2e206c4..b41aff3 100644 --- a/src/cmd/send.rs +++ b/src/cmd/send.rs @@ -6,6 +6,14 @@ use chrono::{DateTime, Utc}; use clap::Args; use std::path::PathBuf; +#[derive(clap::ValueEnum, Debug, Clone, Default, PartialEq)] +pub enum ParseMode { + #[default] + None, + Markdown, + Html, +} + #[derive(Args, Debug, Clone)] pub struct SendArgs { /// Recipient chat ID @@ -55,6 +63,10 @@ pub struct SendArgs { /// Schedule message to be sent in N seconds from now #[arg(long, conflicts_with = "schedule")] pub schedule_in: Option, + + /// Message parse mode: none (plain text), markdown, or html + #[arg(long, value_enum, default_value = "none")] + pub parse_mode: ParseMode, } /// Parse schedule arguments and return the scheduled DateTime if provided @@ -209,6 +221,12 @@ pub async fn run(cli: &Cli, args: &SendArgs) -> Result<()> { .as_ref() .expect("message required when no sticker"); + let parse_mode = match args.parse_mode { + ParseMode::Markdown => "markdown", + ParseMode::Html => "html", + ParseMode::None => "none", + }; + // Direct connection let mut app = App::new(cli).await?; @@ -216,17 +234,17 @@ pub async fn run(cli: &Cli, args: &SendArgs) -> Result<()> { if schedule_time.is_some() { anyhow::bail!("--schedule/--schedule-in is not supported with --topic yet"); } - app.send_text_to_topic(args.to, topic_id, message).await? + app.send_text_to_topic(args.to, topic_id, message, parse_mode).await? } else if let Some(reply_to_id) = args.reply_to { if schedule_time.is_some() { anyhow::bail!("--schedule/--schedule-in is not supported with --reply-to yet"); } - app.send_text_reply(args.to, message, reply_to_id).await? + app.send_text_reply(args.to, message, reply_to_id, parse_mode).await? } else if let Some(schedule_dt) = schedule_time { - app.send_text_scheduled(args.to, message, schedule_dt) + app.send_text_scheduled(args.to, message, schedule_dt, parse_mode) .await? } else { - app.send_text(args.to, message).await? + app.send_text(args.to, message, parse_mode).await? }; if cli.output.is_json() {