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_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": html },
});
if let Some(kb) = reply_markup {
body["reply_markup"] = kb.clone();
}
post_and_check(&url, &body, origin, origin_detail).await
}
pub(crate) async fn send_rich_markdown_id(
api_url: &str,
token: &str,
chat_id: i64,
thread_id: Option<ThreadId>,
markdown: &str,
origin: &str,
origin_detail: &str,
) -> anyhow::Result<i32> {
let url = format!("{}/bot{token}/sendRichMessage", api_base(api_url));
let result = post_rich(
&url,
&build_body(chat_id, thread_id, 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;
loop {
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,
);
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 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,
) -> serde_json::Value {
let mut body = serde_json::json!({
"chat_id": chat_id,
"rich_message": { "markdown": markdown },
});
if let Some(t) = thread_id {
body["message_thread_id"] = serde_json::json!(t.0.0);
}
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": 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_id(
api_url: &str,
token: &str,
chat_id: i64,
thread_id: Option<ThreadId>,
markdown: &str,
media: &[super::mermaid::MediaEntry],
origin: &str,
origin_detail: &str,
) -> anyhow::Result<i32> {
let url = format!("{}/bot{token}/sendRichMessage", api_base(api_url));
let result = post_rich(
&url,
&build_body_markdown_media(chat_id, thread_id, markdown, media),
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 build_body_markdown_media(
chat_id: i64,
thread_id: Option<ThreadId>,
markdown: &str,
media: &[super::mermaid::MediaEntry],
) -> serde_json::Value {
let media_arr: Vec<serde_json::Value> = media
.iter()
.map(|m| {
serde_json::json!({
"id": m.id,
"media": { "type": "photo", "media": m.url },
})
})
.collect();
let mut body = serde_json::json!({
"chat_id": chat_id,
"rich_message": { "markdown": markdown, "media": media_arr },
});
if let Some(t) = thread_id {
body["message_thread_id"] = serde_json::json!(t.0.0);
}
body
}
fn api_base(api_url: &str) -> &str {
api_url.trim_end_matches('/')
}