use teloxide::Bot;
use teloxide::payloads::ForwardMessageSetters;
use teloxide::payloads::SendChatActionSetters;
use teloxide::payloads::SendDocumentSetters;
use teloxide::payloads::SendLocationSetters;
use teloxide::payloads::SendMessageSetters;
use teloxide::payloads::SendPhotoSetters;
use teloxide::payloads::SendPollSetters;
use teloxide::prelude::Requester;
use teloxide::requests::JsonRequest;
use teloxide::types::{ChatAction, ChatId, InlineKeyboardMarkup, InputFile, MessageId, ThreadId};
pub async fn latest_thread_id_for_chat(chat_id: i64) -> Option<ThreadId> {
let pool = crate::db::global_pool()?;
let repo = crate::db::ChannelMessageRepository::new(pool.clone());
let chat_id_str = chat_id.to_string();
let rows = repo
.recent(Some("telegram"), &chat_id_str, 1, None, None)
.await
.ok()?;
let row = rows.into_iter().next()?;
let tid_str = row.thread_id?;
tid_str
.parse::<i32>()
.ok()
.and_then(|n| super::session_resolve::delivery_thread_id(Some(n)))
}
pub fn message_in_thread<C, T>(
bot: &Bot,
chat_id: C,
thread_id: Option<ThreadId>,
text: T,
) -> JsonRequest<teloxide::payloads::SendMessage>
where
C: Into<ChatId>,
T: Into<String>,
{
let req = bot.send_message(chat_id.into(), text.into());
match thread_id {
Some(t) => req.message_thread_id(t),
None => req,
}
}
pub fn photo_in_thread<C>(
bot: &Bot,
chat_id: C,
thread_id: Option<ThreadId>,
photo: InputFile,
) -> teloxide::requests::MultipartRequest<teloxide::payloads::SendPhoto>
where
C: Into<ChatId>,
{
let req = bot.send_photo(chat_id.into(), photo);
match thread_id {
Some(t) => req.message_thread_id(t),
None => req,
}
}
pub fn document_in_thread<C>(
bot: &Bot,
chat_id: C,
thread_id: Option<ThreadId>,
document: InputFile,
) -> teloxide::requests::MultipartRequest<teloxide::payloads::SendDocument>
where
C: Into<ChatId>,
{
let req = bot.send_document(chat_id.into(), document);
match thread_id {
Some(t) => req.message_thread_id(t),
None => req,
}
}
pub fn location_in_thread<C>(
bot: &Bot,
chat_id: C,
thread_id: Option<ThreadId>,
latitude: f64,
longitude: f64,
) -> JsonRequest<teloxide::payloads::SendLocation>
where
C: Into<ChatId>,
{
let req = bot.send_location(chat_id.into(), latitude, longitude);
match thread_id {
Some(t) => req.message_thread_id(t),
None => req,
}
}
pub fn poll_in_thread<C>(
bot: &Bot,
chat_id: C,
thread_id: Option<ThreadId>,
question: String,
options: Vec<teloxide::types::InputPollOption>,
) -> JsonRequest<teloxide::payloads::SendPoll>
where
C: Into<ChatId>,
{
let req = bot.send_poll(chat_id.into(), question, options);
match thread_id {
Some(t) => req.message_thread_id(t),
None => req,
}
}
pub fn forward_in_thread<C>(
bot: &Bot,
to_chat_id: C,
from_chat_id: ChatId,
message_id: MessageId,
thread_id: Option<ThreadId>,
) -> JsonRequest<teloxide::payloads::ForwardMessage>
where
C: Into<ChatId>,
{
let req = bot.forward_message(to_chat_id.into(), from_chat_id, message_id);
match thread_id {
Some(t) => req.message_thread_id(t),
None => req,
}
}
pub fn chat_action_in_thread<C>(
bot: &Bot,
chat_id: C,
thread_id: Option<ThreadId>,
action: ChatAction,
) -> JsonRequest<teloxide::payloads::SendChatAction>
where
C: Into<ChatId>,
{
let req = bot.send_chat_action(chat_id.into(), action);
match thread_id {
Some(t) => req.message_thread_id(t),
None => req,
}
}
pub async fn best_effort_delete<C>(bot: &Bot, chat_id: C, msg_id: MessageId, why: &str)
where
C: Into<ChatId>,
{
let chat = chat_id.into();
if let Err(e) = bot.delete_message(chat, msg_id).await {
let text = e.to_string();
let quiet =
text.contains("message to delete not found") || text.contains("message id is invalid");
if !quiet {
tracing::warn!(
"Telegram: best-effort delete failed ({}): chat={} msg={} err={}",
why,
chat.0,
msg_id.0,
e
);
}
}
}
pub async fn fire_chat_action<C>(
bot: &Bot,
chat_id: C,
thread_id: Option<ThreadId>,
action: ChatAction,
why: &str,
) where
C: Into<ChatId>,
{
let chat = chat_id.into();
if !super::governor::admit_chat_action(chat, thread_id.map(|t| t.0.0)).await {
return;
}
if let Err(e) = chat_action_in_thread(bot, chat, thread_id, action)
.await
.map(|_| ())
{
tracing::warn!("Telegram: chat action failed ({}): {}", why, e);
}
}
#[allow(clippy::too_many_arguments)]
pub async fn best_effort_note<C>(
bot: &Bot,
chat_id: C,
thread_id: Option<ThreadId>,
text: &str,
parse_mode: Option<teloxide::types::ParseMode>,
origin: &str,
origin_detail: &str,
why: &str,
) where
C: Into<ChatId>,
{
let chat = chat_id.into();
super::governor::pace_send(chat).await;
let len = text.len();
let hash8 = super::telemetry::content_hash8(text);
let request = message_in_thread(bot, chat, thread_id, text);
let request = match parse_mode {
Some(mode) => request.parse_mode(mode),
None => request,
};
match request.await {
Ok(m) => super::telemetry::log_send_success(
origin,
origin_detail,
"-",
"note",
why,
chat.0,
thread_id.map(|t| t.0.0),
m.id.0,
len,
&hash8,
),
Err(e) => match super::edit_retry::classify(&e) {
super::edit_retry::EditErr::RetryAfter(wait) => {
tracing::warn!(
"Telegram: best-effort note 429 (retry after {wait:?}) — deferring one retry \
({origin}/{origin_detail} {why}): chat={}",
chat.0
);
let bot2 = bot.clone();
let chat2 = chat;
let thread2 = thread_id;
let text2 = text.to_string();
let mode2 = parse_mode;
let origin2 = origin.to_string();
let detail2 = origin_detail.to_string();
let why2 = why.to_string();
super::edit_retry::spawn_deferred(
wait,
move || async move {
let request = message_in_thread(&bot2, chat2, thread2, &text2);
let request = match mode2 {
Some(mode) => request.parse_mode(mode),
None => request,
};
request.await.map(|_| ())
},
move || async move {
tracing::warn!(
"Telegram: best-effort note dropped after deferred retry \
({origin2}/{detail2} {why2}): chat={}",
chat.0
);
},
);
}
super::edit_retry::EditErr::Fatal(msg) => {
tracing::warn!(
"Telegram: best-effort note failed ({origin}/{origin_detail} {why}): chat={} err={msg}",
chat.0
);
}
},
}
}
pub(crate) async fn send_markdown_outbox(
bot: &Bot,
chat_id: ChatId,
mut thread_id: Option<ThreadId>,
markdown: &str,
origin: &str,
origin_detail: &str,
reply_to: Option<i32>,
) -> std::result::Result<Vec<(i32, String)>, String> {
if super::rich::should_send_native_rich(markdown) {
match super::rich::send_rich_with_mermaid_target_id(
bot.api_url().as_str(),
bot.token(),
chat_id.0,
thread_id,
reply_to,
markdown,
origin,
origin_detail,
)
.await
{
Ok(id) => return Ok(vec![(id, markdown.to_string())]),
Err(e) => {
if e.to_string().contains("message thread not found") && thread_id.is_some() {
if let Some(tid) = thread_id {
let evicted = evict_dead_topic(chat_id.0, tid.0.0).await;
tracing::warn!(
"{origin}/{origin_detail}: remembered topic {} is gone \
(message thread not found) — evicted {evicted} rows, retrying unthreaded",
tid.0.0
);
}
thread_id = None;
match super::rich::send_rich_with_mermaid_target_id(
bot.api_url().as_str(),
bot.token(),
chat_id.0,
None,
reply_to,
markdown,
origin,
origin_detail,
)
.await
{
Ok(id) => return Ok(vec![(id, markdown.to_string())]),
Err(e2) => {
tracing::warn!(
"{origin}/{origin_detail}: native rich send failed after \
stale-topic fallback ({e2}) — falling back to HTML"
);
}
}
} else {
tracing::warn!(
"{origin}/{origin_detail}: native rich send failed ({e}) — falling back to HTML"
);
}
}
}
}
let thread = thread_id.map(|t| t.0.0);
let html = super::handler::markdown_to_telegram_html(markdown);
let chunks = super::handler::split_message(&html, 4096);
let total = chunks.len();
let mut sent: Vec<(i32, String)> = Vec::new();
for (i, chunk) in chunks.into_iter().enumerate() {
match super::intermediates::send_html_or_plain(
bot, chat_id, thread_id, chunk, origin, reply_to,
)
.await
{
Ok(mid) => {
super::telemetry::log_send_success(
origin,
origin_detail,
"-",
"outbox",
"html_chunk",
chat_id.0,
thread,
mid.0,
chunk.len(),
&super::telemetry::content_hash8(chunk),
);
sent.push((mid.0, chunk.to_string()));
}
Err(e) => {
let es = e.to_string();
if es.contains("message thread not found") && thread_id.is_some() {
if let Some(tid) = thread_id {
let evicted = evict_dead_topic(chat_id.0, tid.0.0).await;
tracing::warn!(
"{origin}/{origin_detail}: HTML ladder hit dead topic {} \
— evicted {evicted} rows, retrying chunk unthreaded",
tid.0.0
);
}
thread_id = None;
match super::intermediates::send_html_or_plain(
bot, chat_id, None, chunk, origin, reply_to,
)
.await
{
Ok(mid) => {
super::telemetry::log_send_success(
origin,
origin_detail,
"-",
"outbox",
"html_chunk_unthreaded",
chat_id.0,
None,
mid.0,
chunk.len(),
&super::telemetry::content_hash8(chunk),
);
sent.push((mid.0, chunk.to_string()));
continue;
}
Err(e2) => {
let partial = if sent.is_empty() {
String::new()
} else {
format!(" ({} of {total} chunks already delivered)", sent.len())
};
return Err(format!(
"{origin}/{origin_detail} chunk {}/{total} failed after \
stale-topic fallback{partial}: {e2}",
i + 1
));
}
}
}
let partial = if sent.is_empty() {
String::new()
} else {
format!(" ({} of {total} chunks already delivered)", sent.len())
};
return Err(format!(
"{origin}/{origin_detail} chunk {}/{total} failed{partial}: {e}",
i + 1
));
}
}
}
Ok(sent)
}
pub(crate) async fn evict_dead_topic(chat_id: i64, thread_id: i32) -> u64 {
let Some(pool) = crate::db::global_pool().cloned() else {
return 0;
};
let repo = crate::db::ChannelMessageRepository::new(pool);
match repo
.clear_thread_for_chat("telegram", &chat_id.to_string(), &thread_id.to_string())
.await
{
Ok(n) => n,
Err(e) => {
tracing::warn!(
"stale-topic eviction for chat {chat_id} thread {thread_id} failed: {e}"
);
0
}
}
}
pub(crate) async fn record_outgoing(
pool: Option<crate::db::Pool>,
chat_id: i64,
thread_id: Option<ThreadId>,
sent: &[(i32, String)],
) {
if sent.is_empty() {
return;
}
let Some(pool) = pool.or_else(|| crate::db::global_pool().cloned()) else {
tracing::warn!("telegram outbox: no DB pool — outgoing messages not persisted");
return;
};
let repo = crate::db::ChannelMessageRepository::new(pool);
let chat_id_str = chat_id.to_string();
let thread = thread_id.map(|t| t.0.0.to_string());
for (mid, content) in sent {
if content.trim().is_empty() {
continue;
}
let cm = crate::db::models::ChannelMessage::new(
"telegram".to_string(),
chat_id_str.clone(),
None,
"bot:opencrabs".to_string(),
"OpenCrabs".to_string(),
content.clone(),
"text".to_string(),
Some(mid.to_string()),
)
.with_thread(thread.clone(), None);
if let Err(e) = repo.insert(&cm).await {
tracing::warn!(
"telegram outbox: failed to persist message {mid} for reply-recovery: {e}"
);
}
}
}
pub(crate) async fn send_buttons_raw(
token: &str,
chat_id: i64,
thread_id: Option<ThreadId>,
html: &str,
keyboard: &InlineKeyboardMarkup,
) -> Result<serde_json::Value, String> {
let mut payload = serde_json::json!({
"chat_id": chat_id,
"text": html,
"parse_mode": "HTML",
"reply_markup": keyboard,
});
if let Some(t) = thread_id {
payload["message_thread_id"] = serde_json::json!(t.0.0);
}
let kb_rows = keyboard.inline_keyboard.len();
let wire_body = serde_json::to_string(&payload).unwrap_or_default();
tracing::info!(
"send_buttons wire: body_len={} body_hash8={} kb_rows={} kb_len={} chat={} thread={:?}",
wire_body.len(),
crate::channels::telegram::telemetry::content_hash8(&wire_body),
kb_rows,
serde_json::to_string(&keyboard)
.map(|s| s.len())
.unwrap_or(0),
chat_id,
thread_id.map(|t| t.0.0),
);
if kb_rows == 0 {
return Err(
"no buttons parsed from 'buttons' input — refusing to send a keyboard-less message"
.to_string(),
);
}
let url = format!("https://api.telegram.org/bot{token}/sendMessage");
let client = reqwest::Client::new();
let mut attempt = 0u32;
loop {
let resp = client
.post(&url)
.json(&payload)
.send()
.await
.map_err(|e| format!("transport: {e}"))?;
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.as_u16() == 429 {
if attempt >= 1 {
return Err("rate-limited after retry".to_string());
}
attempt += 1;
let wait = parsed
.get("parameters")
.and_then(|p| p.get("retry_after"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(5)
.min(15);
tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
continue;
}
if status.is_success()
&& parsed.get("ok").and_then(serde_json::Value::as_bool) == Some(true)
{
return Ok(parsed
.get("result")
.cloned()
.unwrap_or(serde_json::Value::Null));
}
let desc = parsed
.get("description")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown error");
return Err(format!("({status}): {desc}"));
}
}