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, 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().map(|n| ThreadId(MessageId(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>,
{
if let Err(e) = chat_action_in_thread(bot, chat_id, 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();
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) => {
tracing::warn!(
"Telegram: best-effort note failed ({origin}/{origin_detail} {why}): chat={} err={e}",
chat.0
);
}
}
}
pub(crate) async fn send_markdown_outbox(
bot: &Bot,
chat_id: ChatId,
thread_id: Option<ThreadId>,
markdown: &str,
origin: &str,
origin_detail: &str,
) -> std::result::Result<Vec<(i32, String)>, String> {
let thread = thread_id.map(|t| t.0.0);
if super::rich::should_send_native_rich(markdown) {
match super::rich::send_rich_with_mermaid_id(
bot.api_url().as_str(),
bot.token(),
chat_id.0,
thread_id,
markdown,
origin,
origin_detail,
)
.await
{
Ok(id) => return Ok(vec![(id, markdown.to_string())]),
Err(e) => {
tracing::warn!(
"{origin}/{origin_detail}: native rich send failed ({e}) — falling back to HTML"
);
}
}
}
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).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 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 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}"
);
}
}
}