use sendMessageDraft for native streaming output, fallback to editMessageText
Telegram Bot API 9.3+ sendMessageDraft provides smooth streaming text rendering without the flickering of repeated edits. Falls back to editMessageText automatically if the API is unavailable (e.g. older clients or group chats). Also reduces edit interval from 5s to 3s and uses 1s interval for draft mode.
This commit is contained in:
164
src/main.rs
164
src/main.rs
@@ -426,7 +426,8 @@ fn extract_tool_use(msg: &AssistantMessage) -> Option<String> {
|
||||
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<teloxide::types::MessageId> = 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<teloxide::types::MessageId> = 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user