use super::mermaid;
use super::render_html::markdown_to_html_mermaid;
use crate::channels::telegram::suggest_options::enforce_button_fit;
use teloxide::types::ThreadId;
#[allow(clippy::too_many_arguments)]
pub(crate) async fn send_rich_html_id(
api_url: &str,
token: &str,
chat_id: i64,
thread_id: Option<ThreadId>,
html: &str,
reply_markup: Option<&serde_json::Value>,
origin: &str,
origin_detail: &str,
) -> anyhow::Result<i32> {
let url = format!("{}/bot{token}/sendRichMessage", api_base(api_url));
let mut body = build_body_html(chat_id, thread_id, html);
if let Some(kb) = reply_markup {
body["reply_markup"] = kb.clone();
}
let result = post_rich(&url, &body, origin, origin_detail).await?;
result
.get("message_id")
.and_then(serde_json::Value::as_i64)
.map(|id| id as i32)
.ok_or_else(|| anyhow::anyhow!("sendRichMessage ok but response carried no message_id"))
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn edit_rich_markdown(
api_url: &str,
token: &str,
chat_id: i64,
message_id: i32,
markdown: &str,
reply_markup: Option<&serde_json::Value>,
origin: &str,
origin_detail: &str,
) -> anyhow::Result<()> {
let url = format!("{}/bot{token}/editMessageText", api_base(api_url));
let mut body = serde_json::json!({
"chat_id": chat_id,
"message_id": message_id,
"rich_message": { "markdown": enforce_button_fit(markdown) },
});
if let Some(kb) = reply_markup {
body["reply_markup"] = kb.clone();
}
post_and_check(&url, &body, origin, origin_detail).await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn edit_rich_html(
api_url: &str,
token: &str,
chat_id: i64,
message_id: i32,
html: &str,
reply_markup: Option<&serde_json::Value>,
origin: &str,
origin_detail: &str,
) -> anyhow::Result<()> {
let url = format!("{}/bot{token}/editMessageText", api_base(api_url));
let mut body = serde_json::json!({
"chat_id": chat_id,
"message_id": message_id,
"rich_message": { "html": enforce_button_fit(html) },
});
if let Some(kb) = reply_markup {
body["reply_markup"] = kb.clone();
}
post_and_check(&url, &body, origin, origin_detail).await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn edit_rich_markdown_media(
api_url: &str,
token: &str,
chat_id: i64,
message_id: i32,
markdown: &str,
media: &[super::mermaid::MediaEntry],
reply_markup: Option<&serde_json::Value>,
origin: &str,
origin_detail: &str,
) -> anyhow::Result<()> {
let url = format!("{}/bot{token}/editMessageText", api_base(api_url));
let body = build_body_markdown_media_edit(chat_id, message_id, markdown, media);
let body = if let Some(kb) = reply_markup {
let mut b = body;
b["reply_markup"] = kb.clone();
b
} else {
body
};
if media.iter().any(|m| m.bytes.is_some()) {
post_rich_multipart(&url, media, &body, origin, origin_detail).await?;
} else {
post_and_check(&url, &body, origin, origin_detail).await?;
}
Ok(())
}
pub(crate) fn build_body_markdown_media_edit(
chat_id: i64,
message_id: i32,
markdown: &str,
media: &[super::mermaid::MediaEntry],
) -> serde_json::Value {
let media_arr: Vec<serde_json::Value> = media
.iter()
.map(|m| {
let source = match (&m.bytes, &m.url) {
(Some(_), _) => format!("attach://{}", m.id),
(None, Some(url)) => url.clone(),
(None, None) => String::new(),
};
serde_json::json!({
"id": m.id,
"media": { "type": "photo", "media": source },
})
})
.collect();
serde_json::json!({
"chat_id": chat_id,
"message_id": message_id,
"rich_message": { "markdown": enforce_button_fit(markdown), "media": media_arr },
})
}
#[allow(dead_code)]
#[allow(clippy::too_many_arguments)]
pub(crate) async fn send_rich_markdown_id(
api_url: &str,
token: &str,
chat_id: i64,
thread_id: Option<ThreadId>,
markdown: &str,
reply_to: Option<i32>,
origin: &str,
origin_detail: &str,
) -> anyhow::Result<i32> {
send_rich_markdown_target_id(
api_url,
token,
chat_id,
thread_id,
reply_to,
markdown,
origin,
origin_detail,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn send_rich_markdown_target_id(
api_url: &str,
token: &str,
chat_id: i64,
thread_id: Option<ThreadId>,
reply_to: Option<i32>,
markdown: &str,
origin: &str,
origin_detail: &str,
) -> anyhow::Result<i32> {
let markdown = super::normalize_tables(markdown);
let url = format!("{}/bot{token}/sendRichMessage", api_base(api_url));
let result = post_rich(
&url,
&build_body_target(chat_id, thread_id, reply_to, &markdown),
origin,
origin_detail,
)
.await?;
result
.get("message_id")
.and_then(serde_json::Value::as_i64)
.map(|id| id as i32)
.ok_or_else(|| anyhow::anyhow!("sendRichMessage ok but response carried no message_id"))
}
const RICH_MAX_RETRIES: u32 = 3;
fn rich_send_fields<'a>(
url: &'a str,
body: &serde_json::Value,
) -> (&'a str, i64, Option<i32>, usize, String) {
let method = url.rsplit('/').next().unwrap_or("?");
let chat_id = body
.get("chat_id")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let thread = body
.get("message_thread_id")
.and_then(serde_json::Value::as_i64)
.map(|t| t as i32);
let text = body
.pointer("/rich_message/markdown")
.and_then(serde_json::Value::as_str)
.or_else(|| {
body.pointer("/rich_message/html")
.and_then(serde_json::Value::as_str)
})
.unwrap_or("");
(
method,
chat_id,
thread,
text.len(),
crate::channels::telegram::telemetry::content_hash8(text),
)
}
async fn post_rich(
url: &str,
body: &serde_json::Value,
origin: &str,
origin_detail: &str,
) -> anyhow::Result<serde_json::Value> {
let client = reqwest::Client::new();
let mut attempt = 0u32;
let edit = super::edit_dedup::fingerprint(body).filter(|_| url.contains("editMessage"));
if let Some((chat_id, message_id, fp)) = edit
&& super::edit_dedup::is_redundant(chat_id, message_id, fp)
{
tracing::debug!(
"Rich edit skipped: message {message_id} in chat {chat_id} already \
carries this content and markup"
);
return Ok(serde_json::Value::Null);
}
loop {
{
let (_, chat_id, thread, _, _) = rich_send_fields(url, body);
crate::channels::telegram::governor::pace_rich(
teloxide::types::ChatId(chat_id),
thread,
)
.await;
}
let resp = client.post(url).json(body).send().await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
if status.is_success()
&& parsed.get("ok").and_then(serde_json::Value::as_bool) == Some(true)
{
let result = parsed
.get("result")
.cloned()
.unwrap_or(serde_json::Value::Null);
let (method, chat_id, thread, len, hash8) = rich_send_fields(url, body);
let is_edit = url.contains("editMessage");
let kind = if is_edit { "rich_edit" } else { "rich_api" };
let msg_id = result
.get("message_id")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0) as i32;
crate::channels::telegram::telemetry::log_send_success(
origin,
origin_detail,
"-",
kind,
method,
chat_id,
thread,
msg_id,
len,
&hash8,
);
if let Some((chat_id, message_id, fp)) = edit {
super::edit_dedup::remember(chat_id, message_id, fp);
}
return Ok(result);
}
if status.as_u16() == 429 && attempt < RICH_MAX_RETRIES {
let retry_after = parsed
.get("parameters")
.and_then(|p| p.get("retry_after"))
.and_then(|r| r.as_u64())
.unwrap_or(5);
attempt += 1;
crate::channels::telegram::rate_limit::wait_out(
"rich API",
std::time::Duration::from_secs(retry_after),
&format!(" (attempt {attempt}/{RICH_MAX_RETRIES})"),
)
.await;
continue;
}
let desc = parsed
.get("description")
.and_then(serde_json::Value::as_str)
.unwrap_or(&text);
if super::edit_dedup::is_not_modified(desc) {
if let Some((chat_id, message_id, fp)) = edit {
super::edit_dedup::remember(chat_id, message_id, fp);
}
tracing::debug!(
"Rich edit was a no-op: Telegram reports the content and markup are unchanged"
);
return Ok(serde_json::Value::Null);
}
if status.as_u16() == 429 {
tracing::warn!(
"Rich API still rate limited after {RICH_MAX_RETRIES} retries — falling back"
);
}
{
let (method, chat_id, thread, len, hash8) = rich_send_fields(url, body);
crate::channels::telegram::telemetry::log_send_failure(
origin,
origin_detail,
"-",
"rich_api",
method,
chat_id,
thread,
len,
&hash8,
&format!("({status}): {desc}"),
);
}
anyhow::bail!("Telegram rich API error ({status}): {desc}")
}
}
async fn post_and_check(
url: &str,
body: &serde_json::Value,
origin: &str,
origin_detail: &str,
) -> anyhow::Result<()> {
post_rich(url, body, origin, origin_detail)
.await
.map(|_| ())
}
pub(crate) fn build_body(
chat_id: i64,
thread_id: Option<ThreadId>,
markdown: &str,
reply_to: Option<i32>,
) -> serde_json::Value {
build_body_target(chat_id, thread_id, reply_to, markdown)
}
pub(crate) fn build_body_target(
chat_id: i64,
thread_id: Option<ThreadId>,
reply_to: Option<i32>,
markdown: &str,
) -> serde_json::Value {
let mut body = serde_json::json!({
"chat_id": chat_id,
"rich_message": { "markdown": enforce_button_fit(markdown) },
});
if let Some(t) = thread_id {
body["message_thread_id"] = serde_json::json!(t.0.0);
}
if let Some(mid) = reply_to {
body["reply_parameters"] = serde_json::json!({ "message_id": mid });
}
body
}
pub(crate) fn build_body_html(
chat_id: i64,
thread_id: Option<ThreadId>,
html: &str,
) -> serde_json::Value {
let mut body = serde_json::json!({
"chat_id": chat_id,
"rich_message": { "html": enforce_button_fit(html) },
});
if let Some(t) = thread_id {
body["message_thread_id"] = serde_json::json!(t.0.0);
}
body
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn send_rich_markdown_media_target_id(
api_url: &str,
token: &str,
chat_id: i64,
thread_id: Option<ThreadId>,
reply_to: Option<i32>,
markdown: &str,
media: &[super::mermaid::MediaEntry],
origin: &str,
origin_detail: &str,
) -> anyhow::Result<i32> {
let markdown = super::normalize_tables(markdown);
let url = format!("{}/bot{token}/sendRichMessage", api_base(api_url));
let body = build_body_markdown_media_target(chat_id, thread_id, reply_to, &markdown, media);
let result = if media.iter().any(|m| m.bytes.is_some()) {
post_rich_multipart(&url, media, &body, origin, origin_detail).await?
} else {
post_rich(&url, &body, origin, origin_detail).await?
};
result
.get("message_id")
.and_then(serde_json::Value::as_i64)
.map(|id| id as i32)
.ok_or_else(|| anyhow::anyhow!("sendRichMessage ok but response carried no message_id"))
}
pub(crate) fn multipart_scalar_fields(body: &serde_json::Value) -> Vec<(String, String)> {
let mut fields: Vec<(String, String)> = Vec::new();
if let Some(v) = body.get("chat_id") {
fields.push(("chat_id".to_string(), v.to_string()));
}
if let Some(v) = body.get("message_id") {
fields.push(("message_id".to_string(), v.to_string()));
}
if let Some(v) = body.get("message_thread_id") {
fields.push(("message_thread_id".to_string(), v.to_string()));
}
if let Some(v) = body.get("reply_parameters") {
fields.push(("reply_parameters".to_string(), v.to_string()));
}
if let Some(v) = body.get("rich_message") {
fields.push(("rich_message".to_string(), v.to_string()));
}
fields
}
fn build_multipart_form(
media: &[super::mermaid::MediaEntry],
body: &serde_json::Value,
) -> reqwest::multipart::Form {
let mut form = reqwest::multipart::Form::new();
for (name, value) in multipart_scalar_fields(body) {
form = form.text(name, value);
}
for m in media {
if let Some(bytes) = &m.bytes {
let part = reqwest::multipart::Part::bytes(bytes.clone())
.file_name(format!("{}.png", m.id))
.mime_str("image/png")
.expect("image/png is a valid mime");
form = form.part(m.id.clone(), part);
}
}
form
}
async fn post_rich_multipart(
url: &str,
media: &[super::mermaid::MediaEntry],
body: &serde_json::Value,
origin: &str,
origin_detail: &str,
) -> anyhow::Result<serde_json::Value> {
let client = reqwest::Client::new();
let mut attempt = 0u32;
loop {
{
let (_, chat_id, thread, _, _) = rich_send_fields(url, body);
crate::channels::telegram::governor::pace_rich(
teloxide::types::ChatId(chat_id),
thread,
)
.await;
}
let form = build_multipart_form(media, body);
let resp = client.post(url).multipart(form).send().await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
if status.is_success()
&& parsed.get("ok").and_then(serde_json::Value::as_bool) == Some(true)
{
let result = parsed
.get("result")
.cloned()
.unwrap_or(serde_json::Value::Null);
let (method, chat_id, thread, len, hash8) = rich_send_fields(url, body);
let msg_id = result
.get("message_id")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0) as i32;
crate::channels::telegram::telemetry::log_send_success(
origin,
origin_detail,
"-",
"rich_api",
method,
chat_id,
thread,
msg_id,
len,
&hash8,
);
return Ok(result);
}
if status.as_u16() == 429 && attempt < RICH_MAX_RETRIES {
let retry_after = parsed
.get("parameters")
.and_then(|p| p.get("retry_after"))
.and_then(|r| r.as_u64())
.unwrap_or(5);
attempt += 1;
crate::channels::telegram::rate_limit::wait_out(
"rich API",
std::time::Duration::from_secs(retry_after),
&format!(" (attempt {attempt}/{RICH_MAX_RETRIES})"),
)
.await;
continue;
}
let desc = parsed
.get("description")
.and_then(serde_json::Value::as_str)
.unwrap_or(&text);
if status.as_u16() == 429 {
tracing::warn!(
"Rich API (multipart) still rate limited after {RICH_MAX_RETRIES} retries — falling back"
);
}
{
let (method, chat_id, thread, len, hash8) = rich_send_fields(url, body);
crate::channels::telegram::telemetry::log_send_failure(
origin,
origin_detail,
"-",
"rich_api",
method,
chat_id,
thread,
len,
&hash8,
&format!("({status}): {desc}"),
);
}
anyhow::bail!("Telegram rich API error ({status}): {desc}")
}
}
pub(crate) fn build_body_markdown_media_target(
chat_id: i64,
thread_id: Option<ThreadId>,
reply_to: Option<i32>,
markdown: &str,
media: &[super::mermaid::MediaEntry],
) -> serde_json::Value {
let media_arr: Vec<serde_json::Value> = media
.iter()
.map(|m| {
let source = match (&m.bytes, &m.url) {
(Some(_), _) => format!("attach://{}", m.id),
(None, Some(url)) => url.clone(),
(None, None) => String::new(),
};
serde_json::json!({
"id": m.id,
"media": { "type": "photo", "media": source },
})
})
.collect();
let mut body = serde_json::json!({
"chat_id": chat_id,
"rich_message": { "markdown": enforce_button_fit(markdown), "media": media_arr },
});
if let Some(t) = thread_id {
body["message_thread_id"] = serde_json::json!(t.0.0);
}
if let Some(mid) = reply_to {
body["reply_parameters"] = serde_json::json!({ "message_id": mid });
}
body
}
fn api_base(api_url: &str) -> &str {
api_url.trim_end_matches('/')
}
pub(crate) async fn send_rich_with_mermaid(
api_url: &str,
token: &str,
chat_id: i64,
thread_id: Option<ThreadId>,
markdown: &str,
origin: &str,
origin_detail: &str,
) -> anyhow::Result<()> {
send_rich_with_mermaid_id(
api_url,
token,
chat_id,
thread_id,
markdown,
None,
origin,
origin_detail,
)
.await
.map(|_| ())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn send_rich_with_mermaid_id(
api_url: &str,
token: &str,
chat_id: i64,
thread_id: Option<ThreadId>,
markdown: &str,
reply_to: Option<i32>,
origin: &str,
origin_detail: &str,
) -> anyhow::Result<i32> {
send_rich_with_mermaid_target_id(
api_url,
token,
chat_id,
thread_id,
reply_to,
markdown,
origin,
origin_detail,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn send_rich_with_mermaid_target_id(
api_url: &str,
token: &str,
chat_id: i64,
thread_id: Option<ThreadId>,
reply_to: Option<i32>,
markdown: &str,
origin: &str,
origin_detail: &str,
) -> anyhow::Result<i32> {
if !mermaid::should_render_mermaid(markdown) {
return send_rich_markdown_target_id(
api_url,
token,
chat_id,
thread_id,
reply_to,
markdown,
origin,
origin_detail,
)
.await;
}
let (resolved, media) = mermaid::resolve_markdown_media(markdown).await;
if media.is_empty() {
return send_rich_markdown_target_id(
api_url,
token,
chat_id,
thread_id,
reply_to,
&resolved,
origin,
origin_detail,
)
.await;
}
match send_rich_markdown_media_target_id(
api_url,
token,
chat_id,
thread_id,
reply_to,
&resolved,
&media,
origin,
origin_detail,
)
.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;
send_rich_html_id(
api_url,
token,
chat_id,
thread_id,
&html,
None,
origin,
origin_detail,
)
.await
}
}
}