pub(crate) mod api;
pub(crate) mod ast;
mod inline;
mod list;
pub(crate) mod mermaid;
mod parse;
mod render_html;
pub(crate) mod render_json;
mod table;
pub(crate) use parse::parse_markdown;
pub(crate) use table::reflow_collapsed_tables;
pub(crate) fn prefers_rich_render(text: &str) -> bool {
contains_table(text) || contains_task_list(text)
}
pub(crate) fn contains_table(text: &str) -> bool {
let lines: Vec<String> = text.lines().map(str::to_string).collect();
(0..lines.len()).any(|i| table::try_parse(&lines, i).is_some())
}
pub(crate) fn should_send_native_rich(text: &str) -> bool {
let flag = crate::config::Config::current()
.channels
.telegram
.rich_messages;
let structured = has_rich_structure(text);
tracing::info!(
"Telegram rich verdict: {} (rich_messages={}, structured={}, table={}, len={})",
flag && structured,
flag,
structured,
contains_table(text),
text.len()
);
flag && structured
}
pub(crate) fn has_rich_structure(text: &str) -> bool {
contains_table(text)
|| text.lines().any(|line| {
let t = line.trim_start();
is_atx_heading(t)
|| list::is_item(t)
|| t.starts_with("```")
|| t == "$$"
|| t == "<details>"
|| t == "<details open>"
|| t.starts_with("<details ")
})
}
fn is_atx_heading(t: &str) -> bool {
let hashes = t.chars().take_while(|&c| c == '#').count();
(1..=6).contains(&hashes) && t[hashes..].starts_with(' ')
}
pub(crate) fn contains_task_list(text: &str) -> bool {
text.lines().any(|line| {
let t = line.trim_start();
let after = t
.strip_prefix("- ")
.or_else(|| t.strip_prefix("* "))
.or_else(|| t.strip_prefix("+ "));
matches!(after, Some(rest)
if rest.starts_with("[ ]") || rest.starts_with("[x]") || rest.starts_with("[X]"))
})
}
pub(crate) fn markdown_to_html(text: &str) -> String {
render_html::render_html(&parse_markdown(text))
}
pub(crate) fn markdown_to_html_p(text: &str) -> String {
render_html::render_html_p(&parse_markdown(text))
}
pub(crate) async fn markdown_to_html_mermaid(text: &str) -> String {
let blocks = parse_markdown(text);
let resolved = mermaid::resolve_blocks(blocks).await;
render_html::render_html(&resolved)
}
pub(crate) async fn send_rich_with_mermaid(
token: &str,
chat_id: i64,
thread_id: Option<teloxide::types::ThreadId>,
markdown: &str,
) -> anyhow::Result<()> {
send_rich_with_mermaid_id(token, chat_id, thread_id, markdown)
.await
.map(|_| ())
}
pub(crate) async fn send_rich_with_mermaid_id(
token: &str,
chat_id: i64,
thread_id: Option<teloxide::types::ThreadId>,
markdown: &str,
) -> anyhow::Result<i32> {
if !mermaid::should_render_mermaid(markdown) {
return api::send_rich_markdown_id(token, chat_id, thread_id, markdown).await;
}
let (resolved, media) = mermaid::resolve_markdown_media(markdown).await;
if media.is_empty() {
return api::send_rich_markdown_id(token, chat_id, thread_id, &resolved).await;
}
match api::send_rich_markdown_media_id(token, chat_id, thread_id, &resolved, &media).await {
Ok(id) => Ok(id),
Err(e) => {
tracing::warn!("rich markdown+media send failed ({e}); falling back to html dialect");
let html = markdown_to_html_mermaid(markdown).await;
api::send_rich_html_id(token, chat_id, thread_id, &html, None).await
}
}
}