use super::TelegramState;
use super::flow::{
DisplayItem, StreamingState, append_intermediate_to_flow, append_system_to_flow,
append_tool_group, folded_duplicates_final, last_folded_text, settle_options_reclaim,
take_folded_final,
};
use super::handler::{fire_reaction, map_to_allowed_reaction};
use super::intermediates::send_html_or_plain;
use super::markdown::{markdown_to_telegram_html, split_message};
use super::send::{
best_effort_delete, document_in_thread, message_in_thread, photo_in_thread, voice_in_thread,
};
use crate::brain::agent::AgentService;
use crate::db::ChannelMessageRepository;
use crate::db::models::ChannelMessage as DbChannelMessage;
use crate::utils::sanitize::redact_secrets;
use std::sync::Arc;
use teloxide::payloads::SendDocumentSetters;
use teloxide::prelude::*;
use teloxide::types::{InputFile, MessageId, ParseMode};
use uuid::Uuid;
pub(crate) fn is_react_only(text_after_directive: &str) -> bool {
text_after_directive.trim().is_empty()
}
pub(crate) fn is_react_only_turn(suppressed_final: bool, text_after_directive: &str) -> bool {
!suppressed_final && is_react_only(text_after_directive)
}
fn options_pending(streaming: &Arc<std::sync::Mutex<StreamingState>>) -> bool {
streaming
.lock()
.unwrap_or_else(|e| e.into_inner())
.pending_suggestions
.is_some()
}
pub(crate) fn is_no_media_found(e: &anyhow::Error) -> bool {
format!("{e:#}").contains("RICH_MESSAGE_PHOTO_NO_MEDIA_FOUND")
}
pub(crate) use crate::channels::background_work::{bg_indicator_for, subagent_counts_for};
#[allow(clippy::too_many_arguments)]
pub(crate) async fn deliver_final_response(
bot: &Bot,
chat_id: ChatId,
inbound: Option<&Message>,
thread_id: Option<teloxide::types::ThreadId>,
streaming: &Arc<std::sync::Mutex<StreamingState>>,
session_id: Uuid,
agent: &Arc<AgentService>,
telegram_state: &Arc<TelegramState>,
channel_msg_repo: &ChannelMessageRepository,
voice_config: &crate::config::VoiceConfig,
is_voice: bool,
is_dm: bool,
chat_title: &str,
mut streaming_msg_id: Option<MessageId>,
result: Result<crate::brain::agent::AgentResponse, crate::brain::agent::AgentError>,
) -> ResponseResult<bool> {
match result {
Ok(response) => {
let mut final_bubble: Option<super::state::MergeBubble> = None;
let (text_only, img_paths) = crate::utils::extract_img_markers(&response.content);
let text_only = crate::utils::sanitize::strip_llm_artifacts(&text_only);
let text_only = redact_secrets(&text_only);
let session_cwd = agent.get_working_directory_for_session(session_id);
let mut file_scan =
crate::utils::image::extract_local_files(&text_only, Some(session_cwd.as_path()));
let text_only = std::mem::take(&mut file_scan.text);
let text_only = match crate::utils::plan_files::load_plan(session_id).await {
Some(plan) if !plan.title.trim().is_empty() => {
strip_echoed_plan_title(&text_only, &plan.title)
}
_ => text_only,
};
let (text_only, react_emoji) = crate::utils::extract_react_marker(&text_only);
let sent = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.sent_intermediates.clone()
};
tracing::info!(
"Telegram dedup: response.content len={}, sent_intermediates count={}",
text_only.len(),
sent.len(),
);
let pre_dedup_text = text_only.clone();
let norm = |s: &str| -> String { s.split_whitespace().collect::<Vec<_>>().join(" ") };
let mut suppressed_final = false;
let text_only = if !sent.is_empty() {
let norm_final = norm(&text_only);
if sent.iter().any(|i| norm(i) == norm_final) {
tracing::info!(
"Telegram dedup: match found among {} intermediates (normalized) — suppressing final response",
sent.len()
);
suppressed_final = true;
String::new()
} else {
text_only
}
} else {
text_only
};
let mut pre_reclaimed = false;
let text_only = if text_only.trim().is_empty() {
if suppressed_final {
let (discarded, discarded_trailer) =
take_folded_final(bot, chat_id, streaming, options_pending(streaming))
.await;
if discarded.is_some() || discarded_trailer.is_some() {
tracing::info!(
"Telegram: final suppressed by dedup — dropping {}(+{}) folded \
chars already delivered as intermediates (#1152)",
discarded.as_ref().map(|t| t.len()).unwrap_or(0),
discarded_trailer.as_ref().map(|t| t.len()).unwrap_or(0),
);
}
text_only
} else {
let (reclaimed, trailer) =
take_folded_final(bot, chat_id, streaming, options_pending(streaming))
.await;
if let Some(t) = trailer {
let trailer_len = t.len();
streaming
.lock()
.unwrap_or_else(|e| e.into_inner())
.pending_trailer = Some(t);
tracing::info!(
"Telegram: stashed {} trailer chars reclaimed before the react \
decision (#31)",
trailer_len
);
}
match reclaimed {
Some(reclaimed) => {
pre_reclaimed = true;
tracing::info!(
"Telegram: reclaimed folded final ({} chars) before react \
decision (#478)",
reclaimed.len()
);
reclaimed
}
None => text_only,
}
}
} else {
text_only
};
let turn_ran_tools = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
!s.tool_msgs.is_empty()
};
if let Some(ref emoji) = react_emoji {
let mapped = map_to_allowed_reaction(emoji);
let reaction = teloxide::types::ReactionType::Emoji {
emoji: mapped.clone(),
};
super::telemetry::log_request(
"turn",
"delivery reaction",
&session_id.to_string(),
"reaction",
"setMessageReaction",
chat_id.0,
None,
inbound.map(|m| i64::from(m.id.0)),
);
let react_result = match inbound {
Some(m) => bot
.set_message_reaction(chat_id, m.id)
.reaction(vec![reaction])
.is_big(false)
.await
.map(|_| ()),
None => Ok(()),
};
if let Err(ref e) = react_result {
tracing::warn!("Telegram: failed to set reaction ({mapped}): {}", e);
}
if text_only.trim().is_empty() && turn_ran_tools && !suppressed_final {
tracing::warn!(
"Telegram: turn executed tools but produced no completion text — \
delivering fallback summary instead of reaction-only skip (#439)"
);
let fallback = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
let done = s
.tool_msgs
.iter()
.filter(|t| t.completed == Some(true))
.count();
format!(
"Done — {done}/{} tool calls completed. (The model ended the turn \
without a summary; see the log above for what ran.)",
s.tool_msgs.len()
)
};
if let Err(e) = message_in_thread(bot, chat_id, thread_id, &fallback).await {
tracing::error!("Telegram: fallback completion send failed: {}", e);
}
if let Some(mid) = streaming_msg_id {
best_effort_delete(bot, chat_id, mid, "fallback send cleanup").await;
}
return Ok(false);
}
if is_react_only_turn(suppressed_final, &text_only) {
if react_result.is_err() {
tracing::warn!(
"Telegram: reaction-only turn with failed reaction — \
delivering the emoji as text instead"
);
if let Err(e) =
message_in_thread(bot, chat_id, thread_id, emoji.as_str()).await
{
tracing::error!("Telegram: emoji text fallback also failed: {}", e);
}
} else {
let elapsed = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.turn_started_at.elapsed()
};
if elapsed >= std::time::Duration::from_secs(60) {
tracing::warn!(
"Telegram: react-only after {}s with no tools and no text — \
surfacing as an incomplete turn, not a silent drop (#546)",
elapsed.as_secs()
);
let notice = "⚠️ I ended that turn with only a reaction and did not \
complete the request. Please re-send it if you needed \
me to act.";
if let Err(e) = message_in_thread(bot, chat_id, thread_id, notice).await
{
tracing::error!(
"Telegram: #546 incomplete-turn notice failed: {}",
e
);
}
} else {
tracing::info!(
"Telegram: reaction-only response ({}), skipping text delivery",
mapped
);
}
}
let flow_mid = {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.flow_entries.clear();
s.open_group_msg_id.take()
};
if let Some(mid) = flow_mid {
best_effort_delete(bot, chat_id, mid, "flow teardown").await;
telegram_state.clear_flow_state(session_id).await;
}
if let Some(mid) = streaming_msg_id {
best_effort_delete(bot, chat_id, mid, "streaming teardown").await;
}
return Ok(false);
}
}
let ctx_max = agent.context_limit_for_session(session_id);
let footer = crate::utils::format_ctx_footer(
response.context_tokens,
ctx_max,
response.tokens_per_second,
);
let footer = if !footer.is_empty() && agent.pressure_warning_active(session_id) {
format!("{footer} ❕")
} else {
footer
};
{
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.sections.ctx = (!footer.is_empty()).then(|| footer.clone());
}
for img_path in img_paths {
match tokio::fs::read(&img_path).await {
Ok(bytes) => {
if let Err(e) =
photo_in_thread(bot, chat_id, thread_id, InputFile::memory(bytes)).await
{
tracing::error!("Telegram: failed to send generated image: {}", e);
}
}
Err(e) => {
tracing::error!("Telegram: failed to read image {}: {}", img_path, e);
}
}
}
{
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
if !s.delivered_files.is_empty() {
file_scan
.attachments
.retain(|f| !s.delivered_files.iter().any(|d| d.path == f.path));
}
}
let (delivered, send_failures) = send_local_files(
session_id,
bot,
chat_id,
thread_id,
&std::mem::take(&mut file_scan.attachments),
)
.await;
file_scan.failures.extend(send_failures);
let text_only =
crate::utils::image::append_file_failure_notice(&text_only, &file_scan.failures);
let mut file_links: Vec<(std::path::PathBuf, String)> = match inbound {
Some(inbound) => delivered
.iter()
.filter_map(|delivered_file| {
let link = file_message_link(
&inbound.chat.kind,
chat_id.0,
thread_id,
delivered_file.message_id,
)?;
Some((delivered_file.path.clone(), link))
})
.collect(),
None => Vec::new(),
};
if let Some(inbound) = inbound {
let from_intermediates = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.delivered_files.clone()
};
for file in from_intermediates {
if file_links.iter().any(|(p, _)| p == &file.path) {
continue;
}
if let Some(link) =
file_message_link(&inbound.chat.kind, chat_id.0, thread_id, file.message_id)
{
file_links.push((file.path, link));
}
}
}
let text_only = link_file_markers(&text_only, &file_scan, &file_links);
let fallback_dup = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
super::intermediates::fallback_would_duplicate(
&s.media_intermediates,
&pre_dedup_text,
)
};
let text_only = if text_only.is_empty()
&& !sent.is_empty()
&& !fallback_dup
&& super::rich::should_send_native_rich_for(
&pre_dedup_text,
options_pending(streaming),
) {
let rich_md = link_file_markers(&pre_dedup_text, &file_scan, &file_links);
match super::rich::send_rich_with_mermaid_id(
bot.api_url().as_str(),
bot.token(),
chat_id.0,
thread_id,
&rich_md,
None,
"turn",
"-",
)
.await
{
Ok(rich_msg_id) => {
let intermediate_ids = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.intermediate_msg_ids.clone()
};
for mid in &intermediate_ids {
best_effort_delete(bot, chat_id, *mid, "intermediate cleanup").await;
}
tracing::info!(
"Telegram: rich fallback delivered ({} chars), deleted {} HTML intermediates",
rich_md.len(),
intermediate_ids.len()
);
final_bubble = Some(super::state::MergeBubble {
message_id: teloxide::types::MessageId(rich_msg_id),
body: super::state::BubbleBody::Markdown(pre_dedup_text.clone()),
});
if !is_dm {
let bot_display_name = telegram_state
.bot_username()
.await
.map(|u| format!("@{}", u))
.unwrap_or_else(|| "OpenCrabs".to_string());
let thread_id_str = thread_id.map(|t| t.0.to_string());
let cm = DbChannelMessage::new(
"telegram".to_string(),
chat_id.0.to_string(),
Some(chat_title.to_string()),
"bot:opencrabs".to_string(),
bot_display_name,
pre_dedup_text.clone(),
"text".to_string(),
Some(rich_msg_id.to_string()),
)
.with_thread(thread_id_str, None);
if let Err(e) = channel_msg_repo.insert(&cm).await {
tracing::warn!(
"Telegram: rich fallback: failed to record bot reply: {}",
e
);
}
}
text_only
}
Err(e) => {
tracing::warn!(
"Telegram: rich fallback failed, keeping HTML intermediates: {e}"
);
text_only
}
}
} else {
text_only
};
let (text_only, reclaimed_trailer) = if text_only.trim().is_empty() {
let (host, trailer) =
take_folded_final(bot, chat_id, streaming, options_pending(streaming)).await;
settle_options_reclaim(text_only, host, trailer)
} else if options_pending(streaming) {
let (host, trailer) = take_folded_final(bot, chat_id, streaming, true).await;
if host.is_none() && trailer.is_none() && !pre_reclaimed {
tracing::warn!(
"Telegram: options pending but flow reclaim returned nothing — \
answer stuck in flow block (#1226 K)"
);
}
let (text, trailer) = settle_options_reclaim(text_only, host, trailer);
if let Some(t) = &trailer {
tracing::info!(
"Telegram: options reclaim settled — {} trailer chars ride after \
the buttons (#31)",
t.len()
);
}
(text, trailer)
} else {
let trailing_matches = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
match last_folded_text(&s.flow_entries) {
Some(folded) => folded_duplicates_final(folded, &text_only),
None => false,
}
};
if trailing_matches {
take_folded_final(bot, chat_id, streaming, options_pending(streaming)).await;
(text_only, None)
} else {
(text_only, None)
}
};
if let Some(trailer) = reclaimed_trailer {
streaming
.lock()
.unwrap_or_else(|e| e.into_inner())
.pending_trailer = Some(trailer);
}
let text_only = super::rich::reflow_collapsed_tables(&text_only);
let html = markdown_to_telegram_html(&text_only);
let display_html = html.clone();
tracing::info!(
"Telegram deliver: html.len={}, ctx footer on flow='{}'",
html.len(),
footer
);
let mut sent_reply_id: Option<i32> = None;
if !display_html.is_empty() {
let mut delivered_rich = super::rich::should_send_native_rich_for(
&text_only,
options_pending(streaming),
) && {
let rich_md = text_only.clone();
if let Some(mid) = streaming_msg_id.take() {
best_effort_delete(bot, chat_id, mid, "pre-rich-fallback cleanup").await;
}
{
let mut rich_send = super::rich::send_rich_with_mermaid_id(
bot.api_url().as_str(),
bot.token(),
chat_id.0,
thread_id,
&rich_md,
None,
"turn",
"-",
)
.await;
if rich_send.is_err() && is_no_media_found(rich_send.as_ref().unwrap_err())
{
tracing::warn!(
"Telegram: rich send hit NO_MEDIA_FOUND (renderer flake?) — retrying once"
);
rich_send = super::rich::send_rich_with_mermaid_id(
bot.api_url().as_str(),
bot.token(),
chat_id.0,
thread_id,
&rich_md,
None,
"turn",
"-",
)
.await;
}
match rich_send {
Ok(id) => {
tracing::info!(
"Telegram: rich markdown delivered as msg {id} ({} chars)",
rich_md.len()
);
sent_reply_id = Some(id);
final_bubble = Some(super::state::MergeBubble {
message_id: teloxide::types::MessageId(id),
body: super::state::BubbleBody::Markdown(rich_md.clone()),
});
true
}
Err(e) => {
tracing::warn!("Telegram: rich delivery failed, using HTML: {e}");
false
}
}
}
};
if !delivered_rich {
if super::rich::mermaid::should_render_mermaid(&text_only) {
let fallback_html =
super::rich::markdown_to_html_mermaid_p(&text_only).await;
match super::rich::api::send_rich_html_id(
bot.api_url().as_str(),
bot.token(),
chat_id.0,
thread_id,
&fallback_html,
None,
"turn",
"-",
)
.await
{
Ok(id) => {
tracing::info!(
"Telegram: rich-html mermaid fallback delivered as msg {id}"
);
sent_reply_id = Some(id);
delivered_rich = true;
}
Err(e2) => {
tracing::warn!(
"Telegram: rich-html mermaid fallback failed ({e2}); degrading to chunks"
);
}
}
}
}
if !delivered_rich {
let chunks: Vec<String> = split_message(&display_html, 4096)
.into_iter()
.map(|s| s.to_string())
.collect();
if chunks.len() == 1
&& let Some(mid) = streaming_msg_id
{
match bot
.edit_message_text(chat_id, mid, &chunks[0])
.parse_mode(ParseMode::Html)
.await
{
Ok(_) => {
super::telemetry::log_send_success(
"turn",
"-",
&session_id.to_string(),
"stream_edit_final",
"in_place_edit",
chat_id.0,
thread_id.map(|t| t.0.0),
mid.0,
chunks[0].len(),
&super::telemetry::content_hash8(&chunks[0]),
);
sent_reply_id = Some(mid.0);
final_bubble = Some(super::state::MergeBubble {
message_id: mid,
body: super::state::BubbleBody::Html(chunks[0].clone()),
});
}
Err(teloxide::RequestError::RetryAfter(secs)) => {
super::rate_limit::wait_out(
"edit",
secs.duration(),
"",
Some(chat_id.0),
)
.await;
match bot
.edit_message_text(chat_id, mid, &chunks[0])
.parse_mode(ParseMode::Html)
.await
{
Ok(_) => {
sent_reply_id = Some(mid.0);
final_bubble = Some(super::state::MergeBubble {
message_id: mid,
body: super::state::BubbleBody::Html(chunks[0].clone()),
});
}
Err(e) => {
tracing::warn!(
"Telegram: edit retry failed ({e}), falling back to delete+send"
);
best_effort_delete(
bot,
chat_id,
mid,
"edit-retry fallback",
)
.await;
if let Ok(sent) = send_html_or_plain(
bot, chat_id, thread_id, &chunks[0], "turn", None,
)
.await
{
sent_reply_id = Some(sent.0);
final_bubble = Some(super::state::MergeBubble {
message_id: sent,
body: super::state::BubbleBody::Html(
chunks[0].clone(),
),
});
} else {
tracing::error!(
"Telegram: delete+send fallback failed in chat {chat_id}, \
the reply was lost"
);
}
}
}
}
Err(e) => {
tracing::warn!(
"Telegram: edit final failed ({e}), falling back to delete+send"
);
best_effort_delete(bot, chat_id, mid, "edit-final fallback").await;
if let Ok(sent) = send_html_or_plain(
bot, chat_id, thread_id, &chunks[0], "turn", None,
)
.await
{
sent_reply_id = Some(sent.0);
final_bubble = Some(super::state::MergeBubble {
message_id: sent,
body: super::state::BubbleBody::Html(chunks[0].clone()),
});
}
}
}
} else {
if let Some(mid) = streaming_msg_id {
best_effort_delete(bot, chat_id, mid, "multi-chunk swap").await;
}
for chunk in &chunks {
if let Ok(sent) =
send_html_or_plain(bot, chat_id, thread_id, chunk, "turn", None)
.await
{
sent_reply_id = Some(sent.0);
final_bubble = Some(super::state::MergeBubble {
message_id: sent,
body: super::state::BubbleBody::Html(chunk.clone()),
});
}
}
}
}
} else if let Some(mid) = streaming_msg_id {
best_effort_delete(bot, chat_id, mid, "empty-final placeholder").await;
}
if final_bubble.is_some() {
streaming
.lock()
.unwrap_or_else(|e| e.into_inner())
.final_bubble = final_bubble;
}
let pmid = sent_reply_id.map(|i| i.to_string());
if !text_only.trim().is_empty() && (!is_dm || pmid.is_some()) {
let bot_display_name = telegram_state
.bot_username()
.await
.map(|u| format!("@{}", u))
.unwrap_or_else(|| "OpenCrabs".to_string());
let thread_id = thread_id.map(|t| t.0.to_string());
let cm = DbChannelMessage::new(
"telegram".to_string(),
chat_id.0.to_string(),
Some(chat_title.to_string()),
"bot:opencrabs".to_string(),
bot_display_name,
text_only.clone(),
"text".to_string(),
pmid.clone(),
)
.with_thread(thread_id, None);
if let Err(e) = channel_msg_repo.insert(&cm).await {
tracing::warn!(
"Telegram: failed to record bot reply in channel_messages: {}",
e
);
}
}
if is_voice && voice_config.tts_enabled {
tracing::info!(
"Telegram: TTS requested — synthesizing response text (len={})",
response.content.len()
);
match crate::channels::voice::synthesize(&response.content, voice_config).await {
Ok(audio_bytes) => {
tracing::info!(
"Telegram: TTS succeeded — {} bytes of audio, sending to chat {}",
audio_bytes.len(),
chat_id
);
match voice_in_thread(
bot,
chat_id,
thread_id,
InputFile::memory(audio_bytes),
)
.await
{
Ok(m) => {
tracing::info!(
"Telegram: voice message delivered (msg_id={})",
m.id
);
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.voice_msg_ids.push(m.id);
}
Err(e) => {
tracing::error!("Telegram: send_voice failed — {}: {:?}", e, e);
}
}
}
Err(e) => {
tracing::error!("Telegram: TTS synthesis failed: {:#}", e);
}
}
}
}
Err(ref e) if matches!(e, crate::brain::agent::AgentError::Cancelled) => {
tracing::info!("Telegram: agent call cancelled for session {}", session_id);
if let Some(mid) = streaming_msg_id {
best_effort_delete(bot, chat_id, mid, "cancel cleanup").await;
}
}
Err(e) => {
tracing::error!("Telegram: agent error: {}", e);
let user_msg = format!("❌ Error\n\n{}", crate::brain::agent::format_user_error(&e));
if let Some(mid) = streaming_msg_id {
if let Err(e) = bot.edit_message_text(chat_id, mid, user_msg).await {
tracing::warn!(
target: "telegram::send",
chat_id = chat_id.0,
message_id = mid.0,
error = %e,
"final-error edit failed"
);
}
} else {
message_in_thread(bot, chat_id, thread_id, user_msg).await?;
}
}
}
Ok(true)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DeliveredFile {
pub path: std::path::PathBuf,
pub message_id: i32,
}
pub(crate) fn file_message_link(
kind: &teloxide::types::ChatKind,
chat_id: i64,
thread_id: Option<teloxide::types::ThreadId>,
message_id: i32,
) -> Option<String> {
let has_message_link = match kind {
teloxide::types::ChatKind::Public(public) => matches!(
public.kind,
teloxide::types::PublicChatKind::Supergroup { .. }
| teloxide::types::PublicChatKind::Channel { .. }
),
teloxide::types::ChatKind::Private { .. } => false,
};
if !has_message_link {
return None;
}
let chat = chat_id.to_string();
let internal = chat.strip_prefix("-100").unwrap_or(chat.as_str());
Some(match thread_id {
Some(thread) => format!("https://t.me/c/{internal}/{}/{message_id}", thread.0.0),
None => format!("https://t.me/c/{internal}/{message_id}"),
})
}
pub(crate) fn link_file_markers(
text: &str,
scan: &crate::utils::image::LocalFileScan,
links: &[(std::path::PathBuf, String)],
) -> String {
if links.is_empty() {
return text.to_string();
}
let mut spans: Vec<(usize, usize, &str, &str)> = Vec::new();
for file in &scan.attachments {
let Some(span) = file.marker_span.as_ref() else {
continue;
};
let (start, end) = (span.start, span.end);
let Some(marker) = text.get(start..end) else {
continue;
};
if scan.text.get(start..end) != Some(marker) {
continue;
}
let Some((_, link)) = links
.iter()
.find(|(path, _)| path.as_path() == file.path.as_path())
else {
continue;
};
spans.push((start, end, marker, link.as_str()));
}
if spans.is_empty() {
return text.to_string();
}
spans.sort_by_key(|(start, _, _, _)| *start);
let mut linked = text.to_string();
for (start, end, marker, link) in spans.iter().rev() {
linked.replace_range(*start..*end, &format!("[{marker}]({link})"));
}
linked
}
pub(crate) fn document_part_name(path: &std::path::Path) -> String {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default()
}
pub(crate) async fn send_local_files(
session_id: uuid::Uuid,
bot: &Bot,
chat_id: ChatId,
thread_id: Option<teloxide::types::ThreadId>,
files: &[crate::utils::image::LocalFile],
) -> (
Vec<DeliveredFile>,
Vec<crate::utils::image::LocalImageFailure>,
) {
let mut delivered: Vec<DeliveredFile> = Vec::new();
let mut failures: Vec<crate::utils::image::LocalImageFailure> = Vec::new();
for file in files {
let path = &file.path;
let bytes = match tokio::fs::read(path).await {
Ok(bytes) => bytes,
Err(e) => {
tracing::error!("Telegram: failed to read file {}: {}", path.display(), e);
failures.push(crate::utils::image::LocalImageFailure {
raw: path.display().to_string(),
resolved: Some(path.clone()),
reason: crate::utils::image::LocalImageFailureReason::Unreadable,
});
continue;
}
};
let len = bytes.len();
let mut req = document_in_thread(
bot,
chat_id,
thread_id,
InputFile::memory(bytes).file_name(document_part_name(path)),
);
if let Some(caption) = file.caption.as_deref() {
req = req.caption(caption.to_string());
}
match req.await {
Ok(msg) => {
delivered.push(DeliveredFile {
path: path.clone(),
message_id: msg.id.0,
});
super::telemetry::log_send_success(
"turn",
"-",
&session_id.to_string(),
"delivery_media",
"file_document",
chat_id.0,
thread_id.map(|t| t.0.0),
msg.id.0,
len,
&super::telemetry::content_hash8(&path.display().to_string()),
);
}
Err(e) => {
tracing::error!(
"Telegram: failed to send file {} as document: {}",
path.display(),
e
);
failures.push(crate::utils::image::LocalImageFailure {
raw: path.display().to_string(),
resolved: Some(path.clone()),
reason: crate::utils::image::LocalImageFailureReason::DeliveryFailed,
});
}
}
}
(delivered, failures)
}
pub(crate) async fn drain_remaining_display(
bot: &Bot,
chat: ChatId,
thread_id: Option<teloxide::types::ThreadId>,
streaming: &Arc<std::sync::Mutex<StreamingState>>,
remaining: Vec<DisplayItem>,
react_target: Option<MessageId>,
) {
let mut tool_buffer: Vec<usize> = Vec::new();
for item in remaining {
match item {
DisplayItem::NewTool(idx) => {
tool_buffer.push(idx);
}
DisplayItem::Intermediate(text) => {
append_tool_group(bot, chat, thread_id, streaming, &tool_buffer).await;
tool_buffer.clear();
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);
if let (Some(target), Some(emoji)) = (react_target, react_emoji.as_deref()) {
fire_reaction(bot, chat, target, emoji).await;
}
append_intermediate_to_flow(bot, chat, thread_id, streaming, &text).await;
}
DisplayItem::System(text) => {
append_tool_group(bot, chat, thread_id, streaming, &tool_buffer).await;
tool_buffer.clear();
let text = redact_secrets(&text);
append_system_to_flow(bot, chat, thread_id, streaming, &text).await;
}
}
}
append_tool_group(bot, chat, thread_id, streaming, &tool_buffer).await;
}
pub(crate) fn strip_echoed_plan_title(text: &str, plan_title: &str) -> String {
let title = normalize_title_line(plan_title);
if title.is_empty() {
return text.to_string();
}
let trimmed = text.trim_start();
let Some(first_line) = trimmed.lines().next() else {
return text.to_string();
};
if normalize_title_line(first_line) != title {
return text.to_string();
}
let rest = trimmed[first_line.len()..].trim_start_matches('\n');
rest.to_string()
}
fn normalize_title_line(line: &str) -> String {
let mut s = line.trim();
s = s.trim_start_matches(['#', '>', '-', '*', '_', '~', ' ']);
s = s.trim_start_matches(|c: char| !c.is_alphanumeric() && c != '"');
for label in ["Plan:", "plan:", "PLAN:"] {
if let Some(rest) = s.strip_prefix(label) {
s = rest.trim_start();
break;
}
}
s = s.trim_matches(['"', '\'', '*', '~', '_', ' ']);
s.trim().to_string()
}