diff --git a/Cargo.lock b/Cargo.lock index 19280b8..1ec623a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,6 +39,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" @@ -51,6 +57,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -382,6 +394,17 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -406,7 +429,26 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -452,6 +494,16 @@ dependencies = [ "itoa", ] +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + [[package]] name = "http-body" version = "0.4.6" @@ -459,7 +511,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -485,9 +560,9 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -499,6 +574,43 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper 1.9.0", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -506,12 +618,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", - "hyper", + "hyper 0.14.32", "native-tls", "tokio", "tokio-native-tls", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.9.0", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "system-configuration 0.7.0", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -669,6 +822,16 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "itertools" version = "0.9.0" @@ -813,6 +976,7 @@ dependencies = [ "anyhow", "chrono", "dptree", + "reqwest 0.12.28", "serde", "serde_json", "serde_yaml", @@ -1060,16 +1224,16 @@ version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ - "base64", + "base64 0.21.7", "bytes", "encoding_rs", "futures-core", "futures-util", - "h2", - "http", - "http-body", - "hyper", - "hyper-tls", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls 0.5.0", "ipnet", "js-sys", "log", @@ -1083,8 +1247,8 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", - "system-configuration", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", "tokio", "tokio-native-tls", "tokio-util", @@ -1097,6 +1261,60 @@ dependencies = [ "winreg", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-rustls", + "hyper-tls 0.6.0", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1119,13 +1337,46 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64", + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -1339,6 +1590,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -1367,6 +1624,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1386,7 +1652,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "system-configuration-sys", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", ] [[package]] @@ -1399,6 +1676,16 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "take_mut" version = "0.2.2" @@ -1455,7 +1742,7 @@ dependencies = [ "once_cell", "pin-project", "rc-box", - "reqwest", + "reqwest 0.11.27", "serde", "serde_json", "serde_with_macros", @@ -1487,7 +1774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1570,6 +1857,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -1594,6 +1891,45 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -1691,6 +2027,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1716,7 +2058,7 @@ version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "getrandom", + "getrandom 0.4.2", "js-sys", "sha1_smol", "wasm-bindgen", @@ -1926,6 +2268,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -2240,6 +2593,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index dbeee0f..8fe2a4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ dptree = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" +reqwest = { version = "0.12", features = ["json"] } teloxide = { version = "0.12", features = ["macros"] } tokio = { version = "1", features = ["full"] } uuid = { version = "1", features = ["v5"] } diff --git a/src/main.rs b/src/main.rs index a8a9b51..7c1d8a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -426,7 +426,8 @@ fn extract_tool_use(msg: &AssistantMessage) -> Option { None } -const EDIT_INTERVAL_MS: u64 = 5000; +const EDIT_INTERVAL_MS: u64 = 3000; +const DRAFT_INTERVAL_MS: u64 = 1000; const TG_MSG_LIMIT: usize = 4096; async fn invoke_claude_streaming( @@ -452,6 +453,30 @@ async fn invoke_claude_streaming( } } +async fn send_message_draft( + client: &reqwest::Client, + token: &str, + chat_id: i64, + draft_id: i64, + text: &str, +) -> Result<()> { + let url = format!("https://api.telegram.org/bot{token}/sendMessageDraft"); + let resp = client + .post(&url) + .json(&serde_json::json!({ + "chat_id": chat_id, + "draft_id": draft_id, + "text": text, + })) + .send() + .await?; + let body: serde_json::Value = resp.json().await?; + if body["ok"].as_bool() != Some(true) { + anyhow::bail!("sendMessageDraft: {}", body); + } + Ok(()) +} + async fn run_claude_streaming( extra_args: &[&str], prompt: &str, @@ -477,16 +502,19 @@ async fn run_claude_streaming( let stdout = child.stdout.take().unwrap(); let mut lines = tokio::io::BufReader::new(stdout).lines(); - // send placeholder immediately so user knows we're on it - let mut msg_id: Option = match bot.send_message(chat_id, CURSOR).await { - Ok(sent) => Some(sent.id), - Err(_) => None, - }; + // sendMessageDraft for native streaming, with editMessageText fallback + let http = reqwest::Client::new(); + let token = bot.token().to_owned(); + let raw_chat_id = chat_id.0; + let draft_id: i64 = 1; + let mut use_draft = true; + + let mut msg_id: Option = None; let mut last_sent_text = String::new(); let mut last_edit = Instant::now(); let mut final_result = String::new(); let mut is_error = false; - let mut tool_status = String::new(); // current tool use status line + let mut tool_status = String::new(); while let Ok(Some(line)) = lines.next_line().await { let event: StreamEvent = match serde_json::from_str(&line) { @@ -496,48 +524,80 @@ async fn run_claude_streaming( match event.event_type.as_str() { "assistant" => { - if let Some(msg) = &event.message { - // check for tool use — show status - if let Some(status) = extract_tool_use(msg) { - tool_status = format!("[{status}]"); - let display = if last_sent_text.is_empty() { - tool_status.clone() + if let Some(amsg) = &event.message { + // determine display content + let (display_raw, new_text) = + if let Some(status) = extract_tool_use(amsg) { + tool_status = format!("[{status}]"); + let d = if last_sent_text.is_empty() { + tool_status.clone() + } else { + format!("{last_sent_text}\n\n{tool_status}") + }; + (d, None) } else { - format!("{last_sent_text}\n\n{tool_status}") + let text = extract_text(amsg); + if text.is_empty() || text == last_sent_text { + continue; + } + let interval = if use_draft { + DRAFT_INTERVAL_MS + } else { + EDIT_INTERVAL_MS + }; + if last_edit.elapsed().as_millis() < interval as u128 { + continue; + } + tool_status.clear(); + (text.clone(), Some(text)) }; - let display = truncate_for_display(&display); - if let Some(id) = msg_id { - let _ = bot.edit_message_text(chat_id, id, &display).await; - } else if let Ok(sent) = bot.send_message(chat_id, &display).await { - msg_id = Some(sent.id); + let display = truncate_for_display(&display_raw); + + if use_draft { + match send_message_draft( + &http, &token, raw_chat_id, draft_id, &display, + ) + .await + { + Ok(_) => { + if let Some(t) = new_text { + last_sent_text = t; + } + last_edit = Instant::now(); + } + Err(e) => { + warn!("sendMessageDraft failed, falling back: {e:#}"); + use_draft = false; + if let Ok(sent) = + bot.send_message(chat_id, &display).await + { + msg_id = Some(sent.id); + if let Some(t) = new_text { + last_sent_text = t; + } + last_edit = Instant::now(); + } + } } - last_edit = Instant::now(); - continue; - } - - // check for text content - let text = extract_text(msg); - if text.is_empty() || text == last_sent_text { - continue; - } - - // throttle edits - if last_edit.elapsed().as_millis() < EDIT_INTERVAL_MS as u128 { - continue; - } - - tool_status.clear(); - let display = truncate_for_display(&text); - - if let Some(id) = msg_id { - if bot.edit_message_text(chat_id, id, &display).await.is_ok() { - last_sent_text = text; + } else if let Some(id) = msg_id { + if bot + .edit_message_text(chat_id, id, &display) + .await + .is_ok() + { + if let Some(t) = new_text { + last_sent_text = t; + } last_edit = Instant::now(); } - } else if let Ok(sent) = bot.send_message(chat_id, &display).await { + } else if let Ok(sent) = + bot.send_message(chat_id, &display).await + { msg_id = Some(sent.id); - last_sent_text = text; + if let Some(t) = new_text { + last_sent_text = t; + } last_edit = Instant::now(); } } @@ -576,10 +636,12 @@ async fn run_claude_streaming( } else { format!("claude exited: {:?}", status) }; - if let Some(id) = msg_id { - let _ = bot - .edit_message_text(chat_id, id, format!("[error] {err_detail}")) - .await; + if !use_draft { + if let Some(id) = msg_id { + let _ = bot + .edit_message_text(chat_id, id, format!("[error] {err_detail}")) + .await; + } } anyhow::bail!("{err_detail}"); } @@ -588,18 +650,18 @@ async fn run_claude_streaming( return Ok(final_result); } - // final update: replace streaming message with complete result + // final result: send as real message(s) — draft auto-disappears let chunks: Vec<&str> = split_msg(&final_result, TG_MSG_LIMIT); - if let Some(id) = msg_id { - // edit first message with final text + if !use_draft && msg_id.is_some() { + // edit mode: replace streaming message with final text + let id = msg_id.unwrap(); let _ = bot.edit_message_text(chat_id, id, chunks[0]).await; - // send remaining chunks as new messages for chunk in &chunks[1..] { let _ = bot.send_message(chat_id, *chunk).await; } } else { - // never got to send a streaming message, send all now + // draft mode or no existing message: sendMessage replaces the draft for chunk in &chunks { let _ = bot.send_message(chat_id, *chunk).await; }