use crate::{
bot::Router,
ctx::Ctx,
error::{Error, Result},
keyboard::{ButtonKind, Reply},
platform::PlatformKind,
};
use std::sync::Arc;
use teloxide::{
payloads::{EditMessageTextSetters, SendMessageSetters},
prelude::*,
types::{
CallbackQuery, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageId,
ParseMode, ReplyParameters, ThreadId,
},
};
pub async fn run(token: String, router: Arc<Router>) -> Result<()> {
tracing::info!("starting telegram adapter");
let bot = build_bot(&token)?;
let msg_router = router.clone();
let cbq_router = router.clone();
let handler = dptree::entry()
.branch(
Update::filter_message().endpoint(move |bot: teloxide::Bot, msg: Message| {
let router = Arc::clone(&msg_router);
async move {
if let Err(e) = handle_message(&bot, &msg, &router).await {
tracing::warn!(error = %e, "telegram handler error");
}
respond(())
}
}),
)
.branch(Update::filter_callback_query().endpoint(
move |bot: teloxide::Bot, q: CallbackQuery| {
let router = Arc::clone(&cbq_router);
async move {
if let Err(e) = handle_callback(&bot, &q, &router).await {
tracing::warn!(error = %e, "telegram callback error");
}
respond(())
}
},
));
Dispatcher::builder(bot, handler).build().dispatch().await;
Ok(())
}
async fn handle_message(bot: &teloxide::Bot, msg: &Message, router: &Router) -> Result<()> {
let Some(text) = msg.text() else {
return Ok(());
};
let chat_id = msg.chat.id;
let user_id = msg
.from
.as_ref()
.map(|u| u.id.0.to_string())
.unwrap_or_default();
let is_dm = Some(msg.chat.is_private());
let source_msg_id = msg.id;
let thread_id = msg.thread_id;
let bot_clone = bot.clone();
let reply_fn: crate::ctx::ReplyFn = Box::new(move |reply: Reply| {
let bot = bot_clone.clone();
Box::pin(async move {
send_tg_message(&bot, chat_id, Some(source_msg_id), thread_id, reply).await
})
});
let ctx = Ctx::new_full(
PlatformKind::Telegram,
chat_id.0.to_string(),
user_id,
text.to_owned(),
reply_fn,
is_dm,
None,
);
router.dispatch(ctx).await
}
async fn handle_callback(bot: &teloxide::Bot, q: &CallbackQuery, router: &Router) -> Result<()> {
let Some(maybe_msg) = q.message.as_ref() else {
let _ = bot.answer_callback_query(&q.id).await;
return Ok(());
};
let chat_id = maybe_msg.chat().id;
let user_id = q.from.id.0.to_string();
let data = q.data.clone().unwrap_or_default();
let is_dm = Some(maybe_msg.chat().is_private());
let thread_id = thread_id_of(maybe_msg);
let bot_msg_id = maybe_msg.id();
let _ = bot.answer_callback_query(&q.id).await;
let bot_for_reply = bot.clone();
let reply_fn: crate::ctx::ReplyFn = Box::new(move |reply: Reply| {
let bot = bot_for_reply.clone();
Box::pin(async move {
send_tg_message(&bot, chat_id, Some(bot_msg_id), thread_id, reply).await
})
});
let bot_for_edit = bot.clone();
let edit_fn: crate::ctx::EditFn = std::sync::Arc::new(move |reply: Reply| {
let bot = bot_for_edit.clone();
Box::pin(async move { edit_tg_message(&bot, chat_id, bot_msg_id, reply).await })
});
let ctx = Ctx::new_with_edit(
PlatformKind::Telegram,
chat_id.0.to_string(),
user_id,
data.clone(),
reply_fn,
is_dm,
Some(data),
Some(edit_fn),
);
router.dispatch(ctx).await
}
fn build_bot(token: &str) -> Result<teloxide::Bot> {
let proxy = std::env::var("HTTPS_PROXY")
.ok()
.or_else(|| std::env::var("https_proxy").ok())
.or_else(|| std::env::var("HTTP_PROXY").ok())
.or_else(|| std::env::var("http_proxy").ok())
.or_else(|| std::env::var("ALL_PROXY").ok())
.or_else(|| std::env::var("all_proxy").ok())
.filter(|s| !s.trim().is_empty());
let mut builder =
reqwest::Client::builder().connect_timeout(std::time::Duration::from_secs(20));
if let Some(url) = proxy.as_deref() {
match reqwest::Proxy::all(url) {
Ok(p) => {
tracing::info!(proxy = %url, "telegram adapter using proxy");
builder = builder.proxy(p);
}
Err(e) => {
tracing::warn!(proxy = %url, error = %e, "ignoring bad proxy URL");
}
}
}
let client = builder
.build()
.map_err(|e| Error::platform("telegram", format!("reqwest client build: {e}")))?;
Ok(teloxide::Bot::with_client(token, client))
}
fn thread_id_of(maybe_msg: &teloxide::types::MaybeInaccessibleMessage) -> Option<ThreadId> {
match maybe_msg {
teloxide::types::MaybeInaccessibleMessage::Regular(m) => m.thread_id,
teloxide::types::MaybeInaccessibleMessage::Inaccessible(_) => None,
}
}
async fn send_tg_message(
bot: &teloxide::Bot,
chat_id: ChatId,
reply_to: Option<MessageId>,
thread_id: Option<ThreadId>,
reply: Reply,
) -> Result<()> {
let (body, use_html) = render_for_telegram(&reply);
let mut req = bot.send_message(chat_id, body);
if use_html {
req = req.parse_mode(ParseMode::Html);
}
if let Some(id) = reply_to {
req = req.reply_parameters(ReplyParameters::new(id).allow_sending_without_reply());
}
if let Some(tid) = thread_id {
req = req.message_thread_id(tid);
}
if let Some(kb) = reply.get_keyboard() {
req = req.reply_markup(to_tg_markup(kb));
}
req.await.map_err(|e| Error::platform("telegram", e))?;
Ok(())
}
async fn edit_tg_message(
bot: &teloxide::Bot,
chat_id: ChatId,
msg_id: MessageId,
reply: Reply,
) -> Result<()> {
let (body, use_html) = render_for_telegram(&reply);
let markup = reply.get_keyboard().map(to_tg_markup);
let mut edit = bot.edit_message_text(chat_id, msg_id, body);
if use_html {
edit = edit.parse_mode(ParseMode::Html);
}
if let Some(m) = markup {
edit = edit.reply_markup(m);
}
match edit.await {
Ok(_) => Ok(()),
Err(e) => {
if format!("{e}").contains("message is not modified") {
return Ok(());
}
Err(Error::platform("telegram", e))
}
}
}
fn render_for_telegram(reply: &Reply) -> (String, bool) {
let Some(em) = reply.get_embed() else {
return (reply.get_text().to_owned(), false);
};
let mut out = String::new();
if let Some(title) = em.get_title() {
let escaped = html_escape(title);
match em.get_url() {
Some(u) => out.push_str(&format!(
"<b><a href=\"{}\">{escaped}</a></b>\n",
html_escape(u)
)),
None => out.push_str(&format!("<b>{escaped}</b>\n")),
}
}
if let Some(desc) = em.get_description() {
out.push_str(&html_escape(desc));
out.push('\n');
}
if !em.get_fields().is_empty() {
if em.get_title().is_some() || em.get_description().is_some() {
out.push('\n');
}
for f in em.get_fields() {
out.push_str(&format!(
"<b>{}</b>\n{}\n",
html_escape(f.name()),
html_escape(f.value())
));
}
}
if let Some(foot) = em.get_footer() {
out.push_str(&format!("\n<i>{}</i>", html_escape(foot)));
}
if let Some(img) = em.get_image() {
out.push_str(&format!("\n\n{}", img));
}
if !reply.get_text().is_empty() {
let head = html_escape(reply.get_text());
out = format!("{head}\n\n{out}");
}
while out.ends_with(|c: char| c.is_whitespace()) {
out.pop();
}
(out, true)
}
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(c),
}
}
out
}
fn to_tg_markup(kb: &crate::keyboard::Keyboard) -> InlineKeyboardMarkup {
let rows: Vec<Vec<InlineKeyboardButton>> = kb
.rows()
.iter()
.map(|row| {
row.iter()
.map(|btn| match &btn.kind {
ButtonKind::Callback(id) => {
InlineKeyboardButton::callback(btn.label().to_owned(), id.clone())
}
ButtonKind::Url(url) => InlineKeyboardButton::url(
btn.label().to_owned(),
url::Url::parse(url)
.unwrap_or_else(|_| url::Url::parse("https://fouko.xyz").unwrap()),
),
})
.collect()
})
.collect();
InlineKeyboardMarkup::new(rows)
}