use super::handler::StreamingState;
use super::markdown::{markdown_to_telegram_html, split_message, strip_html_tags};
use super::send::message_in_thread;
use std::sync::Arc;
use teloxide::prelude::*;
use teloxide::types::{MessageId, ParseMode, ReplyParameters};
#[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,
local_media: &[super::rich::mermaid::MediaEntry],
) -> Option<MessageId> {
if !super::rich::should_send_native_rich(text) && local_media.is_empty() {
return None;
}
match super::rich::send_rich_with_media_target_id(
bot.api_url().as_str(),
bot.token(),
chat_id.0,
thread_id,
None,
text,
local_media,
"turn",
"-",
)
.await
{
Ok(id) => Some(MessageId(id)),
Err(e) => {
tracing::warn!("Telegram: intermediate rich send failed, using HTML: {e}");
None
}
}
}
pub(crate) fn fallback_would_duplicate(
media: &[(teloxide::types::MessageId, String)],
rich_text: &str,
) -> bool {
let norm = |s: &str| -> String { s.split_whitespace().collect::<Vec<_>>().join(" ") };
let norm_final = norm(rich_text);
media.iter().any(|(_, text)| norm(text) == norm_final)
}
pub(crate) fn is_deliverable_status_report(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() {
return false;
}
let total_chars = trimmed.chars().count();
let line_count = text.lines().filter(|l| !l.trim().is_empty()).count();
let mut in_fence = false;
let mut fence_char = ' ';
let mut fence_len = 0;
let mut has_keyword_heading = false;
let mut has_atx_heading = false;
let mut has_status_callout = false;
const STATUS_KEYWORDS: &[&str] = &[
"status",
"update",
"progress",
"pipeline",
"summary",
"verification",
"verdict",
"phase",
"findings",
"plan",
"results",
];
for line in text.lines() {
let t = line.trim_start();
let leading_spaces = line.len() - t.len();
if leading_spaces < 4 {
let fence_run: String = t.chars().take_while(|&c| c == '`' || c == '~').collect();
if fence_run.len() >= 3 {
let f_char = fence_run.chars().next().unwrap();
if !in_fence {
in_fence = true;
fence_char = f_char;
fence_len = fence_run.len();
continue;
} else if f_char == fence_char && fence_run.len() >= fence_len {
in_fence = false;
continue;
}
}
}
if in_fence {
continue;
}
if super::rich::is_atx_heading(t) {
has_atx_heading = true;
let lower = t.to_lowercase();
if STATUS_KEYWORDS.iter().any(|kw| lower.contains(kw)) {
has_keyword_heading = true;
}
}
let trimmed_line = line.trim();
if let Some(after_gt) = trimmed_line.strip_prefix('>') {
let after_gt = after_gt.trim_start();
if let Some(after_stars) = after_gt.strip_prefix("**") {
let lower_callout = after_stars.to_lowercase();
if lower_callout.starts_with("status")
|| lower_callout.starts_with("update")
|| lower_callout.starts_with("progress")
{
has_status_callout = true;
}
}
}
}
if has_keyword_heading && line_count >= 2 && total_chars >= 50 {
return true;
}
if has_atx_heading && line_count >= 3 && total_chars >= 150 {
return true;
}
if has_status_callout && line_count >= 2 && total_chars >= 60 {
return true;
}
false
}
pub(crate) fn is_deliverable_rich_report(text: &str) -> bool {
let reflowed = super::rich::reflow_collapsed_tables(text);
if super::rich::mermaid::has_mermaid_fence(&reflowed) {
return true;
}
if super::rich::contains_table(&reflowed) && text.trim().chars().count() >= 200 {
return true;
}
is_deliverable_status_report(text)
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn deliver_intermediate_message(
bot: &Bot,
chat: ChatId,
thread_id: Option<teloxide::types::ThreadId>,
streaming: &Arc<std::sync::Mutex<StreamingState>>,
tg: &super::state::TelegramState,
session_id: uuid::Uuid,
base_dir: &std::path::Path,
text: &str,
) -> bool {
let expanded = super::rich::reflow_collapsed_tables(text);
let dedup_text = expanded.as_str();
{
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
if s.sent_intermediates.iter().any(|prev| prev == dedup_text) {
return true;
}
}
let mut file_scan = crate::utils::image::extract_local_files(dedup_text, Some(base_dir));
let marked = std::mem::take(&mut file_scan.text);
let (readable, read_failures) =
crate::utils::image::probe_document_bytes(&file_scan.attachments);
file_scan.failures.extend(read_failures);
let unreadable: Vec<std::path::PathBuf> = file_scan
.attachments
.iter()
.map(|f| f.path.clone())
.filter(|p| !readable.contains_key(p))
.collect();
let fw = crate::utils::image::rewrite_local_files(
dedup_text,
Some(base_dir),
crate::utils::DOC_ID_PREFIX,
&unreadable,
crate::config::Config::current()
.channels
.telegram
.inline_markdown_documents,
);
let mut doc_media: Vec<super::rich::mermaid::MediaEntry> = Vec::new();
let mut all_attached = true;
for entry in &fw.entries {
match readable.get(&entry.file.path) {
Some(bytes) => doc_media.push(super::rich::mermaid::MediaEntry {
id: entry.id.clone(),
url: None,
bytes: Some(bytes.clone()),
kind: super::rich::mermaid::MediaKind::Document,
name: Some(super::delivery::document_part_name(&entry.file.path)),
}),
None => {
all_attached = false;
}
}
}
let plain_text = crate::utils::image::append_file_failure_notice(&marked, &file_scan.failures);
let rich_markdown = if doc_media.is_empty() || !all_attached {
plain_text.clone()
} else {
crate::utils::image::append_file_failure_notice(&fw.rich, &file_scan.failures)
};
if let Some(id) =
try_send_intermediate_rich(bot, chat, thread_id, &rich_markdown, &doc_media).await
{
tg.note_bot_bubble(chat.0, id.0);
{
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
if doc_media.is_empty() {
s.intermediate_msg_ids.push(id);
} else {
s.media_intermediates.push((id, dedup_text.to_string()));
s.delivered_files.extend(fw.entries.iter().map(|entry| {
super::delivery::DeliveredFile {
path: entry.file.path.clone(),
message_id: id.0,
}
}));
}
s.sent_intermediates.push(dedup_text.to_string());
}
return true;
}
if !file_scan.attachments.is_empty() {
let (delivered, send_failures) = super::delivery::send_local_files(
session_id,
bot,
chat,
thread_id,
&file_scan.attachments,
)
.await;
if !delivered.is_empty() {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.delivered_files.extend(delivered.iter().cloned());
}
file_scan.failures.extend(send_failures);
}
let text = crate::utils::image::append_file_failure_notice(&marked, &file_scan.failures);
let html = super::rich::markdown_to_html_mermaid(&text).await;
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", None).await {
Ok(id) => {
tg.note_bot_bubble(chat.0, id.0);
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(dedup_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})"),
None,
)
.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,
reply_to: Option<i32>,
) -> std::result::Result<MessageId, teloxide::RequestError> {
super::governor::pace_send(chat_id).await;
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", || {
let mut req = message_in_thread(bot, chat_id, thread_id, html);
if let Some(mid) = reply_to {
req = req.reply_parameters(ReplyParameters::new(MessageId(mid)));
}
req.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", || {
let mut req = message_in_thread(bot, chat_id, thread_id, plain.as_str());
if let Some(mid) = reply_to {
req = req.reply_parameters(ReplyParameters::new(MessageId(mid)));
}
req
})
.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)
}
}
}
}
}