use crate::config::Config;
use std::sync::Arc;
use teloxide::prelude::*;
use teloxide::types::{MessageId, ParseMode};
use super::handler::{escape_html, format_inline, send_html_or_plain};
pub(crate) struct ToolMsg {
pub(crate) msg_id: Option<MessageId>,
pub(crate) name: String,
pub(crate) context: String,
pub(crate) raw_context: String,
pub(crate) completed: Option<bool>,
pub(crate) dirty: bool,
}
#[derive(Clone)]
pub(crate) enum DisplayItem {
NewTool(usize),
Intermediate(String),
}
#[derive(Clone)]
pub(crate) enum FlowEntry {
Tool(usize),
Text(String),
}
pub(crate) struct StreamingState {
pub(crate) msg_id: Option<MessageId>,
pub(crate) thinking: String,
pub(crate) tool_msgs: Vec<ToolMsg>,
pub(crate) display_queue: Vec<DisplayItem>,
pub(crate) open_group_msg_id: Option<MessageId>,
pub(crate) flow_entries: Vec<FlowEntry>,
pub(crate) flow_status: Option<String>,
pub(crate) flow_rich: bool,
pub(crate) response: String,
pub(crate) dirty: bool,
pub(crate) recreate: bool,
pub(crate) status_msg_id: Option<MessageId>,
pub(crate) status_last_text: Option<String>,
pub(crate) tool_round_count: usize,
pub(crate) tools_started_at: Option<std::time::Instant>,
pub(crate) turn_started_at: std::time::Instant,
pub(crate) flow_outcome: Option<FlowOutcome>,
pub(crate) sent_intermediates: Vec<String>,
pub(crate) intermediate_msg_ids: Vec<MessageId>,
pub(crate) voice_msg_ids: Vec<MessageId>,
pub(crate) processing: bool,
pub(crate) user_message_preview: Option<String>,
}
pub(crate) enum FlowLine {
Tool {
label: String,
context: String,
raw_context: String,
},
Text(String),
}
pub(crate) fn latest_activity_preview(lines: &[FlowLine]) -> Option<String> {
if let Some(text) = lines.iter().rev().find_map(|l| match l {
FlowLine::Text(t) => human_readable_preview(t),
FlowLine::Tool { .. } => None,
}) {
return Some(text);
}
if let Some(comments) = lines.iter().rev().find_map(|l| match l {
FlowLine::Tool {
label, raw_context, ..
} if is_bash_tool(label) => extract_status_from_text(raw_context),
_ => None,
}) {
return Some(comments);
}
lines.iter().rev().find_map(|l| match l {
FlowLine::Tool { label, context, .. } => Some(if context.is_empty() {
label.clone()
} else {
format!("{label} {context}")
}),
FlowLine::Text(_) => None,
})
}
fn is_bash_tool(label: &str) -> bool {
label.split_whitespace().last() == Some("bash")
}
pub(crate) fn extract_status_from_text(command: &str) -> Option<String> {
let comments: Vec<String> = command
.lines()
.map(str::trim)
.filter(|l| l.starts_with('#') && !l.starts_with("#!"))
.map(|l| {
l.trim_start_matches('#')
.trim()
.trim_matches(|c: char| c == '-' || c == '=' || c.is_whitespace())
.to_string()
})
.filter(|l| !l.is_empty())
.collect();
(!comments.is_empty()).then(|| comments.join("\n"))
}
fn human_readable_preview(text: &str) -> Option<String> {
let trimmed = text.trim();
let looks_raw = !trimmed.chars().any(char::is_whitespace)
&& (trimmed.contains('/') || !trimmed.chars().any(char::is_alphabetic));
if trimmed.is_empty()
|| trimmed.starts_with('{')
|| trimmed.starts_with('[')
|| trimmed.starts_with("```")
|| looks_raw
{
return None;
}
let cleaned: String = trimmed
.chars()
.filter(|c| !matches!(c, '*' | '`' | '_'))
.collect();
let cleaned = cleaned.trim();
(!cleaned.is_empty()).then(|| cleaned.to_string())
}
const FOLDED_NARRATION_CAP: usize = 300;
fn cap_narration(text: &str) -> String {
if text.chars().count() <= FOLDED_NARRATION_CAP {
return text.to_string();
}
let capped: String = text.chars().take(FOLDED_NARRATION_CAP).collect();
format!("{capped}…")
}
pub(crate) fn render_flow_html(lines: &[FlowLine], live_status: Option<&str>) -> String {
render_flow_html_with(lines, &FlowHeader::Live(live_status))
}
pub(crate) fn render_flow_html_with(lines: &[FlowLine], header: &FlowHeader) -> String {
let mut out: Vec<String> = Vec::new();
let mut tool_count = 0usize;
for line in lines {
match line {
FlowLine::Tool { label, context, .. } => {
tool_count += 1;
if context.is_empty() {
out.push(format!("<b>{}</b>", escape_html(label)));
} else {
out.push(format!(
"<b>{}</b> <code>{}</code>",
escape_html(label),
escape_html(context)
));
}
}
FlowLine::Text(text) => {
let text = text.trim();
if !text.is_empty() {
out.push(format_inline(&escape_html(&cap_narration(text))));
}
}
}
}
if out.is_empty() {
return String::new();
}
if out.len() == 1
&& tool_count == 1
&& let FlowHeader::Live(status) = header
{
return match status {
Some(st) => format!("{} • {}", out.remove(0), st),
None => out.remove(0),
};
}
let status_msg = match header {
FlowHeader::Live(_) => latest_activity_preview(lines).map(|l| escape_html(&l)),
FlowHeader::Settled { .. } => None,
};
format!(
"<blockquote expandable>{}\n\n{}</blockquote>",
flow_header_text(
tool_count,
header,
status_msg.as_deref(),
HeaderMarkup::Html
),
out.join("\n\n")
)
}
pub(crate) fn render_flow_details(lines: &[FlowLine], live_status: Option<&str>) -> String {
render_flow_details_with(lines, &FlowHeader::Live(live_status))
}
pub(crate) fn render_flow_details_with(lines: &[FlowLine], header: &FlowHeader) -> String {
let mut out: Vec<String> = Vec::new();
let mut tool_count = 0usize;
for line in lines {
match line {
FlowLine::Tool { label, context, .. } => {
tool_count += 1;
if context.is_empty() {
out.push(format!("<b>{}</b>", escape_html(label)));
} else {
out.push(format!(
"<b>{}</b> <code>{}</code>",
escape_html(label),
escape_html(context)
));
}
}
FlowLine::Text(text) => {
let text = text.trim();
if !text.is_empty() {
out.push(format_inline(&escape_html(&cap_narration(text))));
}
}
}
}
if out.is_empty() {
return String::new();
}
if out.len() == 1
&& tool_count == 1
&& let FlowHeader::Live(status) = header
{
return match status {
Some(st) => format!("{} • {}", out.remove(0), st),
None => out.remove(0),
};
}
let body: String = out.iter().map(|e| format!("<p>{e}</p>")).collect();
let status_msg = match header {
FlowHeader::Live(_) => latest_activity_preview(lines).map(|l| escape_html(&l)),
FlowHeader::Settled { .. } => None,
};
format!(
"<details><summary><sub>{}</sub></summary>{}</details>",
flow_header_text(
tool_count,
header,
status_msg.as_deref(),
HeaderMarkup::Html
),
body
)
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn render_flow_rich(lines: &[FlowLine], live_status: Option<&str>) -> String {
let mut out: Vec<String> = Vec::new();
let mut tool_count = 0usize;
for line in lines {
match line {
FlowLine::Tool { label, context, .. } => {
tool_count += 1;
if context.is_empty() {
out.push(format!("**{label}**"));
} else {
out.push(format!("**{label}** `{context}`"));
}
}
FlowLine::Text(text) => {
let text = text.trim();
if !text.is_empty() {
out.push(text.to_string());
}
}
}
}
if out.is_empty() {
return String::new();
}
if out.len() == 1 && tool_count == 1 {
return match live_status {
Some(st) => format!("{} • {}", out.remove(0), st),
None => out.remove(0),
};
}
let status_msg = latest_activity_preview(lines);
let header = flow_header_text(
tool_count,
&FlowHeader::Live(live_status),
status_msg.as_deref(),
HeaderMarkup::Markdown,
);
format!("{header}\n\n{}", out.join("\n\n"))
}
pub(crate) fn humanize_elapsed(secs: u64) -> String {
let secs = (secs / 5) * 5;
if secs >= 60 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}s", secs)
}
}
pub(crate) fn humanize_duration(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else {
format!("{} min {}s", secs / 60, secs % 60)
}
}
#[derive(Clone, Copy)]
pub(crate) enum FlowOutcome {
Finished,
Failed,
TimedOut,
}
impl FlowOutcome {
pub(crate) fn icon_verb(self) -> (&'static str, &'static str) {
match self {
FlowOutcome::Finished => ("✅", "Finished"),
FlowOutcome::Failed => ("❌", "Failed"),
FlowOutcome::TimedOut => ("⏱", "Timed out"),
}
}
}
pub(crate) enum FlowHeader<'a> {
Live(Option<&'a str>),
Settled {
icon: &'a str,
verb: &'a str,
duration: &'a str,
},
}
#[derive(Clone, Copy)]
pub(crate) enum HeaderMarkup {
Html,
Markdown,
}
impl HeaderMarkup {
fn bold(self, s: &str) -> String {
match self {
HeaderMarkup::Html => format!("<b>{s}</b>"),
HeaderMarkup::Markdown => format!("**{s}**"),
}
}
fn italic(self, s: &str) -> String {
match self {
HeaderMarkup::Html => format!("<i>{s}</i>"),
HeaderMarkup::Markdown => format!("_{s}_"),
}
}
}
pub(crate) fn flow_header_text(
tool_count: usize,
header: &FlowHeader,
status_msg: Option<&str>,
markup: HeaderMarkup,
) -> String {
let base = if tool_count > 0 {
format!("{tool_count} tool calls")
} else {
"Processing log".to_string()
};
match header {
FlowHeader::Live(duration) => {
if status_msg.is_none() && duration.is_none() {
return markup.bold(&base);
}
let mut segs: Vec<String> = Vec::new();
if let Some(status) = status_msg {
segs.push(markup.bold(status));
}
segs.push(markup.italic(&base));
if let Some(dur) = duration {
segs.push(markup.italic(dur));
}
format!("⚙️ {}", segs.join(" • "))
}
FlowHeader::Settled {
icon,
verb,
duration,
} => {
let text = if tool_count > 0 {
format!("{icon} {verb} ({tool_count} tool calls, {duration})")
} else {
format!("{icon} {verb} ({duration})")
};
markup.bold(&text)
}
}
}
pub(crate) fn tool_status_icon(completed: Option<bool>) -> &'static str {
match completed {
None => "⚙️",
Some(true) => "✅",
Some(false) => "❌",
}
}
pub(crate) fn flow_lines(s: &StreamingState) -> Vec<FlowLine> {
s.flow_entries
.iter()
.filter_map(|entry| match entry {
FlowEntry::Tool(idx) => s.tool_msgs.get(*idx).map(|t| FlowLine::Tool {
label: format!("{} {}", tool_status_icon(t.completed), t.name),
context: t.context.clone(),
raw_context: t.raw_context.clone(),
}),
FlowEntry::Text(text) => Some(FlowLine::Text(text.clone())),
})
.collect()
}
pub(crate) fn render_flow(s: &StreamingState) -> String {
match s.flow_outcome {
Some(outcome) => {
let (icon, verb) = outcome.icon_verb();
let duration = humanize_duration(s.turn_started_at.elapsed().as_secs());
render_flow_html_with(
&flow_lines(s),
&FlowHeader::Settled {
icon,
verb,
duration: &duration,
},
)
}
None => render_flow_html(&flow_lines(s), s.flow_status.as_deref()),
}
}
pub(crate) fn render_flow_details_state(s: &StreamingState) -> String {
match s.flow_outcome {
Some(outcome) => {
let (icon, verb) = outcome.icon_verb();
let duration = humanize_duration(s.turn_started_at.elapsed().as_secs());
render_flow_details_with(
&flow_lines(s),
&FlowHeader::Settled {
icon,
verb,
duration: &duration,
},
)
}
None => render_flow_details(&flow_lines(s), s.flow_status.as_deref()),
}
}
pub(crate) async fn refresh_flow(
bot: &Bot,
chat: ChatId,
streaming: &Arc<std::sync::Mutex<StreamingState>>,
) {
let (mid, rich) = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
match s.open_group_msg_id {
Some(mid) => (mid, s.flow_rich),
None => return,
}
};
if rich {
refresh_flow_rich_details(bot, chat, mid, streaming).await;
} else {
refresh_flow_html(bot, chat, mid, streaming).await;
}
}
pub(crate) async fn refresh_flow_rich_details(
bot: &Bot,
chat: ChatId,
mid: MessageId,
streaming: &Arc<std::sync::Mutex<StreamingState>>,
) {
let details = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
render_flow_details_state(&s)
};
if details.is_empty() {
return;
}
if details.chars().count() > 30000 {
freeze_flow_block(streaming, mid, "rich size limit reached");
return;
}
match super::rich::api::edit_rich_html(bot.token(), chat.0, mid.0, &details).await {
Ok(_) => {}
Err(e) => {
let msg = e.to_string();
if msg.contains("message is not modified") {
return;
}
tracing::warn!(
"Telegram: rich details edit failed for mid={:?}: {msg} — falling back to HTML",
mid
);
refresh_flow_html(bot, chat, mid, streaming).await;
}
}
}
pub(crate) async fn refresh_flow_html(
bot: &Bot,
chat: ChatId,
mid: MessageId,
streaming: &Arc<std::sync::Mutex<StreamingState>>,
) {
let html = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
render_flow(&s)
};
if html.is_empty() {
return;
}
if html.chars().count() > 4000 {
freeze_flow_block(streaming, mid, "size limit reached");
return;
}
match bot
.edit_message_text(chat, mid, html)
.parse_mode(ParseMode::Html)
.await
{
Ok(_) => {}
Err(teloxide::RequestError::RetryAfter(secs)) => {
tracing::warn!(
"Telegram: refresh_flow rate-limited for mid={:?} — waiting {}s, then retrying",
mid,
secs.seconds()
);
tokio::time::sleep(secs.duration()).await;
let retry_html = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
if s.open_group_msg_id != Some(mid) {
return; }
render_flow(&s)
};
if let Err(e) = bot
.edit_message_text(chat, mid, retry_html)
.parse_mode(ParseMode::Html)
.await
{
tracing::warn!(
"Telegram: refresh_flow retry failed for mid={:?}: {} — keeping message, next tick retries",
mid,
e
);
}
}
Err(e) => {
let msg = e.to_string();
if msg.contains("message is not modified") {
} else if msg.contains("MESSAGE_TOO_LONG") {
freeze_flow_block(streaming, mid, "MESSAGE_TOO_LONG");
} else if msg.contains("message to edit not found") {
tracing::warn!(
"Telegram: refresh_flow target mid={:?} no longer exists — starting a new block",
mid
);
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
if s.open_group_msg_id == Some(mid) {
s.open_group_msg_id = None;
s.flow_entries.clear();
}
} else {
tracing::warn!(
"Telegram: refresh_flow edit failed for mid={:?}: {} — keeping message",
mid,
e
);
}
}
}
}
pub(crate) fn detach_flow_for_followup(streaming: &Arc<std::sync::Mutex<StreamingState>>) {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
if s.open_group_msg_id.is_some() {
tracing::info!(
"Telegram: mid-turn follow-up — flow block stays open; restick moves it \
below on the next round (#475)"
);
}
if s.msg_id.is_some() {
s.recreate = true;
}
}
pub(crate) fn freeze_flow_block(
streaming: &Arc<std::sync::Mutex<StreamingState>>,
mid: MessageId,
reason: &str,
) {
tracing::info!(
"Telegram: freezing processing-log block mid={:?} ({reason}) — content stays visible, next entries start a new block",
mid
);
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
if s.open_group_msg_id == Some(mid) {
s.open_group_msg_id = None;
s.flow_entries.clear();
}
}
pub(crate) async fn open_flow(
bot: &Bot,
chat: ChatId,
thread_id: Option<teloxide::types::ThreadId>,
streaming: &Arc<std::sync::Mutex<StreamingState>>,
) {
if Config::current().channels.telegram.rich_messages {
let details = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
render_flow_details_state(&s)
};
if !details.is_empty() {
match super::rich::api::send_rich_html_id(bot.token(), chat.0, thread_id, &details)
.await
{
Ok(mid) => {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.open_group_msg_id = Some(MessageId(mid));
s.flow_rich = true;
if s.msg_id.is_some() {
s.recreate = true;
}
return;
}
Err(e) => {
tracing::warn!(
"Telegram: rich details flow open failed: {e} — falling back to HTML"
);
}
}
}
}
let html = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
render_flow(&s)
};
if html.is_empty() {
return;
}
if let Ok(mid) = send_html_or_plain(bot, chat, thread_id, &html).await {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.open_group_msg_id = Some(mid);
s.flow_rich = false;
if s.msg_id.is_some() {
s.recreate = true;
}
}
}
pub(crate) async fn append_tool_group(
bot: &Bot,
chat: ChatId,
thread_id: Option<teloxide::types::ThreadId>,
streaming: &Arc<std::sync::Mutex<StreamingState>>,
buffer: &[usize],
) {
if buffer.is_empty() {
return;
}
let open = {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
for &idx in buffer {
s.flow_entries.push(FlowEntry::Tool(idx));
}
s.open_group_msg_id
};
if open.is_some() {
{
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
let mid = s.open_group_msg_id;
for &idx in buffer {
if let Some(tool) = s.tool_msgs.get_mut(idx) {
tool.msg_id = mid;
}
}
}
refresh_flow(bot, chat, streaming).await;
} else {
open_flow(bot, chat, thread_id, streaming).await;
let mid = streaming
.lock()
.unwrap_or_else(|e| e.into_inner())
.open_group_msg_id;
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
for &idx in buffer {
if let Some(tool) = s.tool_msgs.get_mut(idx) {
tool.msg_id = mid;
}
}
}
}
pub(crate) async fn append_intermediate_to_flow(
bot: &Bot,
chat: ChatId,
thread_id: Option<teloxide::types::ThreadId>,
streaming: &Arc<std::sync::Mutex<StreamingState>>,
text: &str,
) {
if text.trim().is_empty() {
return;
}
let open = {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.flow_entries.push(FlowEntry::Text(text.to_string()));
s.open_group_msg_id
};
if open.is_some() {
refresh_flow(bot, chat, streaming).await;
} else {
open_flow(bot, chat, thread_id, streaming).await;
}
}
pub(crate) async fn restick_flow_if_buried(
bot: &Bot,
chat: ChatId,
thread_id: Option<teloxide::types::ThreadId>,
streaming: &Arc<std::sync::Mutex<StreamingState>>,
newest_incoming: Option<i32>,
) {
let (old_mid, rich) = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
match s.open_group_msg_id {
Some(mid) => (mid, s.flow_rich),
None => return,
}
};
match newest_incoming {
Some(newest) if newest > old_mid.0 => {}
_ => return,
}
let new_mid: Option<MessageId> = if rich {
let details = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
render_flow_details_state(&s)
};
if details.is_empty() {
return;
}
match super::rich::api::send_rich_html_id(bot.token(), chat.0, thread_id, &details).await {
Ok(mid) => Some(MessageId(mid)),
Err(e) => {
tracing::warn!(
"Telegram: restick rich re-post failed: {e} — keeping buried block in place"
);
None
}
}
} else {
let html = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
render_flow(&s)
};
if html.is_empty() {
return;
}
match send_html_or_plain(bot, chat, thread_id, &html).await {
Ok(mid) => Some(mid),
Err(e) => {
tracing::warn!(
"Telegram: restick HTML re-post failed: {e} — keeping buried block in place"
);
None
}
}
};
let Some(new_mid) = new_mid else {
return;
};
let relocated = {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
if s.open_group_msg_id == Some(old_mid) {
s.open_group_msg_id = Some(new_mid);
for t in s.tool_msgs.iter_mut() {
if t.msg_id == Some(old_mid) {
t.msg_id = Some(new_mid);
}
}
true
} else {
false
}
};
if relocated {
if let Err(e) = bot.delete_message(chat, old_mid).await {
tracing::warn!("Telegram: restick could not delete old block mid={old_mid:?}: {e}");
}
} else if let Err(e) = bot.delete_message(chat, new_mid).await {
tracing::warn!("Telegram: restick could not delete stray duplicate: {e}");
}
}
pub(crate) async fn take_folded_final(
bot: &Bot,
chat: ChatId,
streaming: &Arc<std::sync::Mutex<StreamingState>>,
) -> Option<String> {
let text = {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
pop_trailing_folded_texts(&mut s.flow_entries)
};
text.as_ref()?;
let now_empty = {
let s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.flow_entries.is_empty()
};
if now_empty {
let mid = {
let mut s = streaming.lock().unwrap_or_else(|e| e.into_inner());
s.open_group_msg_id.take()
};
if let Some(mid) = mid {
let _ = bot.delete_message(chat, mid).await;
}
} else {
refresh_flow(bot, chat, streaming).await;
}
text
}
pub(crate) fn pop_trailing_folded_texts(entries: &mut Vec<FlowEntry>) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
while matches!(entries.last(), Some(FlowEntry::Text(_))) {
match entries.pop() {
Some(FlowEntry::Text(t)) => parts.push(t),
other => {
if let Some(e) = other {
entries.push(e);
}
break;
}
}
}
if parts.is_empty() {
return None;
}
parts.reverse();
Some(parts.join("\n\n"))
}
pub(crate) fn folded_duplicates_final(folded: &str, final_text: &str) -> bool {
let norm_folded: String = folded.split_whitespace().collect::<Vec<_>>().join(" ");
let norm_final: String = final_text.split_whitespace().collect::<Vec<_>>().join(" ");
if norm_folded.is_empty() || norm_final.is_empty() {
return false;
}
if norm_folded == norm_final {
return true;
}
let overlap = norm_folded.len().min(norm_final.len());
overlap >= 20 && (norm_final.starts_with(&norm_folded) || norm_folded.starts_with(&norm_final))
}