use super::TelegramState;
use super::flow_chrome::{
GoalSection, PlanKb, ProseSection, load_goal_section, load_plan_prose, load_plan_sections,
};
use super::handler::escape_html;
use super::send::message_in_thread;
use crate::brain::agent::AgentService;
use crate::config::Config;
use crate::utils::truncate_chars;
use std::sync::Arc;
use teloxide::prelude::*;
use teloxide::types::{MessageId, ParseMode, ThreadId};
use uuid::Uuid;
const CARD_PROSE_BUDGET: usize = 2400;
const GOAL_TEXT_CAP: usize = 600;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum CollapsibleStyle {
BlockquoteExpandable,
DetailsSummary,
}
enum CardBlock {
Line(String),
Block(String),
ClassicGap,
}
fn serialize_card(style: CollapsibleStyle, blocks: &[CardBlock]) -> String {
let mut out = String::new();
match style {
CollapsibleStyle::BlockquoteExpandable => {
for b in blocks {
match b {
CardBlock::Line(s) | CardBlock::Block(s) => {
if !out.is_empty() {
out.push('\n');
}
out.push_str(s);
}
CardBlock::ClassicGap => {
if !out.is_empty() {
out.push('\n');
}
}
}
}
}
CollapsibleStyle::DetailsSummary => {
for b in blocks {
match b {
CardBlock::Line(s) => {
out.push_str("<p>");
out.push_str(s);
out.push_str("</p>");
}
CardBlock::Block(s) => out.push_str(s),
CardBlock::ClassicGap => {}
}
}
}
}
out
}
async fn render_plan_card(
style: CollapsibleStyle,
title: Option<&str>,
checklist: Option<&[String]>,
prose: Option<&[ProseSection]>,
goal: Option<&GoalSection>,
) -> Option<String> {
let mut blocks: Vec<CardBlock> = Vec::new();
if let Some(t) = title.map(str::trim).filter(|t| !t.is_empty()) {
blocks.push(CardBlock::Line(format!("📋 <b>{}</b>", escape_html(t))));
}
if let Some(sections) = prose.filter(|s| !s.is_empty()) {
let mut budget: Option<usize> = match style {
CollapsibleStyle::BlockquoteExpandable => Some(CARD_PROSE_BUDGET),
CollapsibleStyle::DetailsSummary => None,
};
for sec in sections {
if budget == Some(0) {
break;
}
let (body, chars_used) = match budget {
Some(remaining) => {
let truncated = truncate_chars(&sec.body, remaining);
(truncated, truncated.chars().count())
}
None => (sec.body.as_str(), 0),
};
budget = budget.map(|b| b.saturating_sub(chars_used));
blocks.push(CardBlock::Block(match (&sec.heading, style) {
(Some(h), CollapsibleStyle::BlockquoteExpandable) => format!(
"<blockquote expandable><b>{}</b>\n{}</blockquote>",
escape_html(h),
super::rich::markdown_to_html(body),
),
(Some(h), CollapsibleStyle::DetailsSummary) => format!(
"<details><summary><b>{}</b></summary>{}</details>",
escape_html(h),
super::rich::markdown_to_html_mermaid_p(body).await,
),
(None, CollapsibleStyle::DetailsSummary) => {
super::rich::markdown_to_html_mermaid_p(body).await
}
(None, CollapsibleStyle::BlockquoteExpandable) => {
super::rich::markdown_to_html(body)
}
}));
}
}
if let Some(rows) = checklist {
for row in rows {
blocks.push(CardBlock::Line(escape_html(row)));
}
}
if let Some(g) = goal {
let text = g.text.trim();
if !text.is_empty() {
let has_prose = prose.is_some_and(|p| !p.is_empty());
if checklist.is_some() || has_prose {
blocks.push(CardBlock::ClassicGap);
}
blocks.push(CardBlock::Block(match style {
CollapsibleStyle::BlockquoteExpandable => {
let capped = escape_html(truncate_chars(text, GOAL_TEXT_CAP));
format!(
"<blockquote expandable>{} {capped}</blockquote>",
g.prefix(true)
)
}
CollapsibleStyle::DetailsSummary => format!(
"<details><summary>{} goal</summary>\n{}</details>",
g.prefix(true),
escape_html(text)
),
}));
}
}
let out = serialize_card(style, &blocks);
(!out.is_empty()).then_some(out)
}
pub(crate) async fn render_plan_card_html(
title: Option<&str>,
checklist: Option<&[String]>,
prose: Option<&[ProseSection]>,
goal: Option<&GoalSection>,
) -> Option<String> {
render_plan_card(
CollapsibleStyle::BlockquoteExpandable,
title,
checklist,
prose,
goal,
)
.await
}
pub(crate) async fn render_plan_card_rich_html(
title: Option<&str>,
checklist: Option<&[String]>,
prose: Option<&[ProseSection]>,
goal: Option<&GoalSection>,
) -> Option<String> {
render_plan_card(
CollapsibleStyle::DetailsSummary,
title,
checklist,
prose,
goal,
)
.await
}
enum EditOutcome {
Saved,
Suppressed,
Gone,
}
async fn handle_edit_failure(
error: &str,
state: &TelegramState,
session_id: Uuid,
chat: ChatId,
thread_id: Option<ThreadId>,
signature: &str,
mid: MessageId,
) -> EditOutcome {
if error.contains("message is not modified") {
state
.set_plan_card(session_id, chat, thread_id, mid, signature.to_string())
.await;
return EditOutcome::Saved;
}
if let Some(wait) = super::rate_limit::parse_retry_after(error) {
tracing::warn!(
"Telegram plan card edit throttled for session {session_id}: {error} — \
pausing card writes for {}s",
wait.as_secs()
);
state
.suppress_plan_card(session_id, wait + super::rate_limit::RETRY_MARGIN)
.await;
return EditOutcome::Suppressed;
}
tracing::debug!("Telegram plan card edit failed ({mid:?}): {error} — recreating");
state.take_plan_card(session_id).await;
EditOutcome::Gone
}
async fn handle_create_failure(error: &str, state: &TelegramState, session_id: Uuid) {
if let Some(wait) = super::rate_limit::parse_retry_after(error) {
tracing::warn!(
"Telegram plan card create throttled for session {session_id}: {error} — \
pausing card writes for {}s",
wait.as_secs()
);
state
.suppress_plan_card(session_id, wait + super::rate_limit::RETRY_MARGIN)
.await;
} else {
tracing::warn!("Telegram plan card create failed: {error}");
}
}
pub(crate) async fn refresh_plan_card(
bot: &Bot,
chat: ChatId,
thread_id: Option<ThreadId>,
state: &Arc<TelegramState>,
agent: &AgentService,
session_id: Uuid,
plan_kb: PlanKb,
) {
if state.plan_card_suppressed(session_id).await {
return;
}
let card_lock = state.plan_card_lock(session_id).await;
let _guard = card_lock.lock().await;
let (title, checklist) = load_plan_sections(session_id).await;
let prose = load_plan_prose(session_id).await;
let goal = if checklist.is_some() {
load_goal_section(agent, session_id)
.await
.map(|(text, completed)| GoalSection { text, completed })
} else {
None
};
let use_rich = Config::current().channels.telegram.rich_messages;
if use_rich
&& let Some(rich_html) = render_plan_card_rich_html(
title.as_deref(),
checklist.as_deref(),
prose.as_deref(),
goal.as_ref(),
)
.await
{
let kb_val = plan_kb
.keyboard()
.and_then(|m| serde_json::to_value(m).ok());
let rich_sig = format!("rich:{rich_html}\u{1}{plan_kb:?}");
if let Some((mid, last_sig)) = state.plan_card(session_id).await {
if last_sig == rich_sig {
return;
}
let admitted = super::governor::edit_admission(
bot,
chat,
mid,
super::governor::EditClass::Final,
rich_html.clone(),
true,
)
.await;
if !admitted {
state
.set_plan_card(session_id, chat, thread_id, mid, rich_sig)
.await;
return;
}
match super::rich::api::edit_rich_html(
bot.api_url().as_str(),
bot.token(),
chat.0,
mid.0,
&rich_html,
kb_val.as_ref(),
"turn",
"-",
)
.await
{
Ok(()) => {
state
.set_plan_card(session_id, chat, thread_id, mid, rich_sig)
.await;
return;
}
Err(e) => {
let outcome = handle_edit_failure(
&e.to_string(),
state,
session_id,
chat,
thread_id,
&rich_sig,
mid,
)
.await;
match outcome {
EditOutcome::Saved | EditOutcome::Suppressed => return,
EditOutcome::Gone => { }
}
}
}
}
super::governor::pace_send(chat).await;
match super::rich::api::send_rich_html_id(
bot.api_url().as_str(),
bot.token(),
chat.0,
thread_id,
&rich_html,
kb_val.as_ref(),
"turn",
"-",
)
.await
{
Ok(mid) => {
state
.set_plan_card(session_id, chat, thread_id, MessageId(mid), rich_sig)
.await;
return;
}
Err(e) => {
tracing::warn!("Rich plan card create failed: {e} — falling back to HTML");
}
}
}
let Some(html) = render_plan_card_html(
title.as_deref(),
checklist.as_deref(),
prose.as_deref(),
goal.as_ref(),
)
.await
else {
if crate::utils::plan_files::peek_plan_just_archived(session_id).await {
finalize_plan_card_locked(bot, chat, thread_id, state, session_id).await;
} else {
remove_plan_card_locked(bot, chat, state, session_id).await;
}
return;
};
let kb = plan_kb.keyboard();
let signature = format!("{html}\u{1}{plan_kb:?}");
if let Some((mid, last_sig)) = state.plan_card(session_id).await {
if last_sig == signature {
return;
}
let admitted = super::governor::edit_admission(
bot,
chat,
mid,
super::governor::EditClass::Final,
html.clone(),
false,
)
.await;
if !admitted {
state
.set_plan_card(session_id, chat, thread_id, mid, signature)
.await;
return;
}
let mut req = bot
.edit_message_text(chat, mid, html.clone())
.parse_mode(ParseMode::Html);
if let Some(ref k) = kb {
req = req.reply_markup(k.clone());
}
match req.await {
Ok(_) => {
state
.set_plan_card(session_id, chat, thread_id, mid, signature)
.await;
return;
}
Err(e) => {
let outcome = handle_edit_failure(
&e.to_string(),
state,
session_id,
chat,
thread_id,
&signature,
mid,
)
.await;
match outcome {
EditOutcome::Saved | EditOutcome::Suppressed => return,
EditOutcome::Gone => { }
}
}
}
}
super::governor::pace_send(chat).await;
let mut req = message_in_thread(bot, chat, thread_id, html).parse_mode(ParseMode::Html);
if let Some(ref k) = kb {
req = req.reply_markup(k.clone());
}
match req.await {
Ok(m) => {
state
.set_plan_card(session_id, chat, thread_id, m.id, signature)
.await
}
Err(e) => {
handle_create_failure(&e.to_string(), state, session_id).await;
}
}
}
pub(crate) async fn finalize_plan_card(
bot: &Bot,
chat: ChatId,
thread_id: Option<ThreadId>,
state: &Arc<TelegramState>,
session_id: Uuid,
) -> bool {
let card_lock = state.plan_card_lock(session_id).await;
let _guard = card_lock.lock().await;
finalize_plan_card_locked(bot, chat, thread_id, state, session_id).await
}
struct FinalizeAbortGuard {
session_id: Uuid,
armed: bool,
}
impl Drop for FinalizeAbortGuard {
fn drop(&mut self) {
if self.armed {
tracing::warn!(
"Telegram plan card finalize aborted mid-flight for session {} \
(task cancelled while awaiting — e.g. unresolved G3 pacing hold); \
just-archived flag retained, next settle retries",
self.session_id
);
}
}
}
async fn finalize_plan_card_locked(
bot: &Bot,
chat: ChatId,
thread_id: Option<ThreadId>,
state: &Arc<TelegramState>,
session_id: Uuid,
) -> bool {
let Some((mid, _sig)) = state.plan_card(session_id).await else {
tracing::warn!(
"Telegram plan card finalize for session {session_id}: no tracked \
card (already finalized or never posted) — consuming just-archived \
flag, completion notice NOT posted"
);
crate::utils::plan_files::take_plan_just_archived(session_id).await;
return true;
};
let Some(doc) = crate::utils::plan_files::latest_archived_plan(session_id).await else {
tracing::warn!(
"Telegram plan card finalize for session {session_id}: no archived \
plan document found — consuming just-archived flag, completion \
notice NOT posted"
);
crate::utils::plan_files::take_plan_just_archived(session_id).await;
return true;
};
let mut abort_guard = FinalizeAbortGuard {
session_id,
armed: true,
};
let (title, checklist) = super::flow_chrome::plan_document_sections(&doc);
let empty_kb = serde_json::json!({ "inline_keyboard": [] });
let use_rich = Config::current().channels.telegram.rich_messages;
let rich = if use_rich {
render_plan_card_rich_html(title.as_deref(), checklist.as_deref(), None, None)
.await
.map(|mut r| {
r = r.replacen("📋", "✅", 1);
r.push_str("\n<i>Plan completed and archived.</i>");
r
})
} else {
None
};
let mut html = render_plan_card_html(title.as_deref(), checklist.as_deref(), None, None)
.await
.unwrap_or_else(|| "<b>Plan</b>".to_string());
html = html.replacen("📋", "✅", 1);
html.push_str("\n<i>Plan completed and archived.</i>");
let mut posted: Option<MessageId> = None;
if use_rich && let Some(rich) = &rich {
super::governor::pace_send(chat).await;
match super::rich::api::send_rich_html_id(
bot.api_url().as_str(),
bot.token(),
chat.0,
thread_id,
rich,
Some(&empty_kb),
"turn",
"-",
)
.await
{
Ok(mid) => posted = Some(MessageId(mid)),
Err(e) => tracing::warn!("Telegram plan card rich restick failed: {e}"),
}
}
if posted.is_none() {
super::governor::pace_send(chat).await;
let req = message_in_thread(bot, chat, thread_id, html.clone()).parse_mode(ParseMode::Html);
match req.await {
Ok(m) => posted = Some(m.id),
Err(e) => tracing::warn!("Telegram plan card restick post failed ({mid:?}): {e}"),
}
}
match posted {
Some(new_mid) => {
crate::utils::plan_files::take_plan_just_archived(session_id).await;
tracing::info!(
"Telegram plan card finalized for session {session_id}: \
completed card posted ({new_mid:?})"
);
if let Some((mid, _)) = state.plan_card(session_id).await
&& new_mid != mid
{
match bot.delete_message(chat, mid).await {
Ok(_) => {
tracing::info!("Telegram plan card restick deleted stale card ({mid:?})")
}
Err(e) => {
tracing::warn!("Telegram plan card restick delete failed ({mid:?}): {e}")
}
}
}
state.take_plan_card(session_id).await;
abort_guard.armed = false;
true
}
None => {
let Some((mid, _)) = state.plan_card(session_id).await else {
tracing::warn!(
"Telegram plan card finalize for session {session_id}: \
tracked card vanished mid-finalize — consuming \
just-archived flag, completion notice NOT posted"
);
crate::utils::plan_files::take_plan_just_archived(session_id).await;
abort_guard.armed = false;
return true;
};
let mut edited = false;
if use_rich && let Some(rich) = &rich {
match super::rich::api::edit_rich_html(
bot.api_url().as_str(),
bot.token(),
chat.0,
mid.0,
rich,
Some(&empty_kb),
"turn",
"-",
)
.await
{
Ok(()) => edited = true,
Err(e) => tracing::warn!(
"Telegram plan card rich finalize edit failed ({mid:?}): {e}"
),
}
}
if !edited {
match bot
.edit_message_text(chat, mid, html.clone())
.parse_mode(teloxide::types::ParseMode::Html)
.reply_markup(super::suggest_options::empty_keyboard())
.await
{
Ok(_) => edited = true,
Err(e) => {
tracing::warn!("Telegram plan card finalize edit failed ({mid:?}): {e}")
}
}
}
abort_guard.armed = false;
if edited {
crate::utils::plan_files::take_plan_just_archived(session_id).await;
tracing::info!(
"Telegram plan card finalized in place ({mid:?}) for session \
{session_id} after post failure"
);
state.take_plan_card(session_id).await;
true
} else {
tracing::warn!(
"Telegram plan card finalize FAILED for session {session_id} \
(post + edit both failed) — just-archived flag retained, \
next settle retries"
);
false
}
}
}
}
pub(crate) async fn remove_plan_card(
bot: &Bot,
chat: ChatId,
state: &Arc<TelegramState>,
session_id: Uuid,
) {
let card_lock = state.plan_card_lock(session_id).await;
let _guard = card_lock.lock().await;
remove_plan_card_locked(bot, chat, state, session_id).await;
}
pub(crate) async fn restick_plan_card_after_turn(
bot: &Bot,
chat: ChatId,
thread_id: Option<ThreadId>,
state: &Arc<TelegramState>,
agent: &AgentService,
session_id: Uuid,
plan_kb: PlanKb,
) {
if crate::utils::plan_files::peek_plan_just_archived(session_id).await {
finalize_plan_card(bot, chat, thread_id, state, session_id).await;
} else {
if state.plan_card_cached(session_id).await.is_some()
&& state.claim_sticky_action(chat.0, TelegramState::STICKY_STACK_MIN_INTERVAL)
{
remove_plan_card(bot, chat, state, session_id).await;
}
refresh_plan_card(bot, chat, thread_id, state, agent, session_id, plan_kb).await;
}
}
async fn remove_plan_card_locked(
bot: &Bot,
chat: ChatId,
state: &Arc<TelegramState>,
session_id: Uuid,
) {
if let Some(mid) = state.take_plan_card(session_id).await {
match bot.delete_message(chat, mid).await {
Ok(_) => {
tracing::info!("Telegram plan card deleted ({mid:?}) for session {session_id}")
}
Err(e) => tracing::warn!("Telegram plan card delete failed ({mid:?}): {e}"),
}
}
}