use super::handler::{DisplayItem, StreamingState};
use super::markdown::{markdown_to_telegram_html, split_message, strip_html_tags};
use super::send::message_in_thread;
use crate::utils::sanitize::redact_secrets;
use std::sync::Arc;
use teloxide::prelude::*;
use teloxide::types::{MessageId, ParseMode};
pub(crate) async fn flush_intermediates(
bot: &Bot,
chat: ChatId,
thread_id: Option<teloxide::types::ThreadId>,
streaming: &Arc<std::sync::Mutex<StreamingState>>,
) {
let pending: Vec<DisplayItem> = {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.display_queue
.drain(..)
.filter(|item| matches!(item, DisplayItem::Intermediate(_)))
.collect()
};
for item in pending {
if let DisplayItem::Intermediate(text) = item {
let text = crate::utils::sanitize::strip_llm_artifacts(&text);
let text = redact_secrets(&text);
let (text, _img_paths) = crate::utils::extract_img_markers(&text);
let (text, _react_emoji) = crate::utils::extract_react_marker(&text);
let text = super::rich::reflow_collapsed_tables(&text);
{
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
if s.sent_intermediates.iter().any(|prev| prev == &text) {
continue;
}
}
if !is_deliverable_rich_report(&text) {
continue;
}
if let Some(id) = try_send_intermediate_rich(bot, chat, thread_id, &text).await {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.sent_intermediates.push(text.clone());
s.intermediate_msg_ids.push(id);
continue;
}
let html = markdown_to_telegram_html(&text);
if html.is_empty() {
continue;
}
let chunks: Vec<String> = split_message(&html, 4096)
.into_iter()
.map(|s| s.to_string())
.collect();
let mut sent_ids: Vec<MessageId> = Vec::new();
let mut all_ok = true;
for chunk in &chunks {
match send_html_or_plain(bot, chat, thread_id, chunk, "turn").await {
Ok(id) => sent_ids.push(id),
Err(e) => {
tracing::warn!(
"Telegram: flush intermediate send failed ({e}), leaving for edit loop"
);
all_ok = false;
break;
}
}
}
if all_ok {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.sent_intermediates.push(text.clone());
s.intermediate_msg_ids.extend(sent_ids);
}
}
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn build_last_intermediate_with_footer(
last_intermediate_text: &str,
footer: &str,
) -> Option<String> {
if footer.is_empty() || last_intermediate_text.is_empty() {
return None;
}
let html = markdown_to_telegram_html(last_intermediate_text);
let chunks = split_message(&html, 4096);
let last_chunk = chunks.last()?;
let combined = format!("{last_chunk}\n\n{footer}");
if combined.chars().count() > 4096 {
None
} else {
Some(combined)
}
}
pub(crate) async fn try_send_intermediate_rich(
bot: &Bot,
chat_id: ChatId,
thread_id: Option<teloxide::types::ThreadId>,
text: &str,
) -> Option<MessageId> {
if !super::rich::should_send_native_rich(text) {
return None;
}
match super::rich::api::send_rich_markdown_id(
bot.api_url().as_str(),
bot.token(),
chat_id.0,
thread_id,
text,
"turn",
"-",
)
.await
{
Ok(id) => Some(MessageId(id)),
Err(e) => {
tracing::warn!("Telegram: intermediate rich send failed, using HTML: {e}");
None
}
}
}
pub(crate) fn is_deliverable_rich_report(text: &str) -> bool {
let reflowed = super::rich::reflow_collapsed_tables(text);
super::rich::contains_table(&reflowed) && text.trim().chars().count() >= 200
}
pub(crate) async fn deliver_intermediate_message(
bot: &Bot,
chat: ChatId,
thread_id: Option<teloxide::types::ThreadId>,
streaming: &Arc<std::sync::Mutex<StreamingState>>,
text: &str,
) -> bool {
let expanded = super::rich::reflow_collapsed_tables(text);
let text = expanded.as_str();
{
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
if s.sent_intermediates.iter().any(|prev| prev == text) {
return true;
}
}
if let Some(id) = try_send_intermediate_rich(bot, chat, thread_id, text).await {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.sent_intermediates.push(text.to_string());
s.intermediate_msg_ids.push(id);
return true;
}
let html = markdown_to_telegram_html(text);
if html.is_empty() {
return false;
}
let mut sent_ids: Vec<MessageId> = Vec::new();
for chunk in split_message(&html, 4096) {
match send_html_or_plain(bot, chat, thread_id, chunk, "turn").await {
Ok(id) => sent_ids.push(id),
Err(e) => {
tracing::warn!("Telegram: rich-intermediate send failed ({e})");
return false;
}
}
}
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.sent_intermediates.push(text.to_string());
s.intermediate_msg_ids.extend(sent_ids);
true
}
const LONG_RATE_LIMIT_THRESHOLD: std::time::Duration = std::time::Duration::from_secs(3600);
pub(crate) async fn send_retrying_rate_limit<T, F, Fut>(
what: &str,
mut send: F,
) -> std::result::Result<T, teloxide::RequestError>
where
F: FnMut() -> Fut,
Fut: std::future::IntoFuture<Output = std::result::Result<T, teloxide::RequestError>>,
{
const MAX_RETRIES: u32 = 3;
let mut attempt = 0u32;
loop {
match send().await {
Err(teloxide::RequestError::RetryAfter(secs)) => {
let requested = secs.duration();
if requested > LONG_RATE_LIMIT_THRESHOLD {
tracing::error!(
"Telegram: {what} long rate-limit ({}s > {}s threshold) — bailing immediately, \
no retry ladder (#1110)",
requested.as_secs(),
LONG_RATE_LIMIT_THRESHOLD.as_secs()
);
return Err(teloxide::RequestError::RetryAfter(secs));
}
if attempt < MAX_RETRIES {
attempt += 1;
super::rate_limit::wait_out(
what,
requested,
&format!(" (attempt {attempt}/{MAX_RETRIES})"),
)
.await;
} else {
tracing::error!(
"Telegram: {what} still rate-limited after {MAX_RETRIES} retries ({}s) — giving up",
requested.as_secs()
);
return Err(teloxide::RequestError::RetryAfter(secs));
}
}
other => return other,
}
}
}
pub(crate) async fn send_html_or_plain(
bot: &Bot,
chat_id: ChatId,
thread_id: Option<teloxide::types::ThreadId>,
html: &str,
origin: &str,
) -> std::result::Result<MessageId, teloxide::RequestError> {
let thread = thread_id.map(|t| t.0.0);
let hash8 = super::telemetry::content_hash8(html);
let len = html.len();
let log_ok = |path: &str, m: &MessageId, len: usize, hash8: &str| {
super::telemetry::log_send_success(
origin,
"-",
"-",
"html_or_plain",
path,
chat_id.0,
thread,
m.0,
len,
hash8,
);
};
match send_retrying_rate_limit("HTML send", || {
message_in_thread(bot, chat_id, thread_id, html).parse_mode(ParseMode::Html)
})
.await
{
Ok(m) => {
log_ok("html", &m.id, len, &hash8);
Ok(m.id)
}
Err(e) => {
tracing::warn!("Telegram: HTML send failed after retries ({e}), sending as plain text");
let plain = strip_html_tags(html);
let plain_hash8 = super::telemetry::content_hash8(&plain);
let plain_len = plain.len();
match send_retrying_rate_limit("plain fallback", || {
message_in_thread(bot, chat_id, thread_id, plain.as_str())
})
.await
{
Ok(m) => {
log_ok("plain_fallback", &m.id, plain_len, &plain_hash8);
Ok(m.id)
}
Err(e2) => {
super::telemetry::log_send_failure(
origin,
"-",
"-",
"html_or_plain",
"plain_fallback",
chat_id.0,
thread,
plain_len,
&plain_hash8,
&e2.to_string(),
);
Err(e2)
}
}
}
}
}