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
}
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_p(body),
),
(None, CollapsibleStyle::DetailsSummary) => super::rich::markdown_to_html_p(body),
(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) 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,
)
}
pub(crate) 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,
)
}
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(),
)
{
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;
}
match super::rich::api::edit_rich_html(
bot.token(),
chat.0,
mid.0,
&rich_html,
kb_val.as_ref(),
)
.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 => { }
}
}
}
}
match super::rich::api::send_rich_html_id(
bot.token(),
chat.0,
thread_id,
&rich_html,
kb_val.as_ref(),
)
.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(),
) 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 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 => { }
}
}
}
}
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 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;
}
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
&& let Err(e) = bot.delete_message(chat, mid).await
{
tracing::debug!("Telegram plan card delete failed ({mid:?}): {e}");
}
}