use crate::{
bot::Router,
ctx::{ChatInfo, ChatInfoFn, Ctx},
error::{Error, Result},
keyboard::{AttachmentKind, ButtonKind, Reply},
platform::PlatformKind,
};
use std::sync::Arc;
use teloxide::{
payloads::{
EditMessageTextSetters, SendAudioSetters, SendMessageSetters, SendPhotoSetters,
SendVideoSetters,
},
prelude::*,
types::{
BotCommand, CallbackQuery, ChatId, InlineKeyboardButton, InlineKeyboardMarkup, InputFile,
Message, MessageId, ParseMode, ReplyParameters, ThreadId,
},
};
pub async fn run(
token: String,
router: Arc<Router>,
commands: Vec<(String, Option<String>, bool)>,
notifier: Option<crate::notifier::Notifier>,
menu_web_app: Option<(String, String)>,
) -> Result<()> {
tracing::info!("starting telegram adapter");
let bot = build_bot(&token)?;
if let Some(notifier) = ¬ifier {
let bot_for_push = bot.clone();
let send: crate::notifier::SendFn = std::sync::Arc::new(move |chat_id: String, reply| {
let bot = bot_for_push.clone();
Box::pin(async move {
let id: i64 = chat_id
.parse()
.map_err(|_| Error::platform("telegram", format!("bad chat id {chat_id}")))?;
send_tg_message(&bot, ChatId(id), None, None, reply)
.await
.map(|_| ())
})
});
notifier
.register(PlatformKind::Telegram, send.clone())
.await;
notifier.register_dm(PlatformKind::Telegram, send).await;
let bot_for_lookup = bot.clone();
let lookup: crate::notifier::UserLookupFn = std::sync::Arc::new(move |user_id: String| {
let bot = bot_for_lookup.clone();
Box::pin(async move {
let id: i64 = user_id
.parse()
.map_err(|_| Error::platform("telegram", format!("bad user id {user_id}")))?;
match bot.get_chat(ChatId(id)).await {
Ok(chat) => Ok(Some(tg_display_name(&chat))),
Err(teloxide::RequestError::Api(teloxide::ApiError::ChatNotFound)) => Ok(None),
Err(e) => Err(Error::platform("telegram", e)),
}
})
});
notifier
.register_user_lookup(PlatformKind::Telegram, lookup)
.await;
}
let menu: Vec<BotCommand> = commands
.iter()
.filter_map(|(name, desc, _takes_user)| {
let trimmed = name.trim_start_matches('/').to_ascii_lowercase();
if !is_valid_tg_command(&trimmed) {
return None;
}
let description = desc
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| trimmed.clone());
let description = truncate_chars(&description, 256);
Some(BotCommand::new(trimmed, description))
})
.collect();
if !menu.is_empty() {
match bot.set_my_commands(menu).await {
Ok(_) => tracing::info!("telegram: published command menu"),
Err(e) => tracing::warn!(error = %e, "telegram: could not set command menu"),
}
}
if let Some((label, url)) = menu_web_app {
publish_menu_button(&bot, &label, &url).await;
}
if let Some(notifier) = ¬ifier {
let bot_for_menu = bot.clone();
let publish: crate::notifier::MenuAppFn =
std::sync::Arc::new(move |label: String, url: String| {
let bot = bot_for_menu.clone();
Box::pin(async move {
publish_menu_button(&bot, &label, &url).await;
Ok(())
})
});
notifier.register_menu_app(publish).await;
}
if router.max_update_age().is_some() {
if let Err(e) = drop_pending_updates(&bot).await {
tracing::warn!(error = %e, "telegram: could not clear pending updates");
} else {
tracing::info!("telegram: cleared backlog of pending updates");
}
}
let mut self_id: Option<u64> = None;
for attempt in 0..3 {
match bot.get_me().await {
Ok(me) => {
self_id = Some(me.id.0);
break;
}
Err(e) => {
tracing::warn!(error = %e, attempt, "telegram: get_me failed");
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
}
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, self_id).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)
.distribution_function(|_| None::<std::convert::Infallible>)
.build()
.dispatch()
.await;
Ok(())
}
async fn publish_menu_button(bot: &teloxide::Bot, label: &str, url: &str) {
match url::Url::parse(url) {
Ok(parsed) if parsed.scheme() != "https" => {
tracing::warn!(url, "telegram: mini app url must be https; button skipped");
}
Ok(parsed) => {
let button = teloxide::types::MenuButton::WebApp {
text: label.to_owned(),
web_app: teloxide::types::WebAppInfo { url: parsed },
};
match bot.set_chat_menu_button().menu_button(button).await {
Ok(_) => tracing::info!("telegram: published mini app menu button"),
Err(e) => {
tracing::warn!(error = %e, "telegram: could not set mini app menu button")
}
}
}
Err(e) => tracing::warn!(error = %e, url, "telegram: bad mini app url; button skipped"),
}
}
async fn handle_message(
bot: &teloxide::Bot,
msg: &Message,
router: &Router,
self_id: Option<u64>,
) -> Result<()> {
let image_file_id = tg_image_file_id(msg);
let text = match msg.text().or_else(|| msg.caption()) {
Some(t) => t.to_owned(),
None if image_file_id.is_some() => String::new(),
None => return Ok(()),
};
if let Some(max_age) = router.max_update_age() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let sent = msg.date.timestamp();
if now.saturating_sub(sent) > max_age.as_secs() as i64 {
tracing::debug!(age_secs = now - sent, "telegram: skipping stale message");
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 user_name = msg.from.as_ref().map(|u| {
let mut name = u.first_name.clone();
if let Some(last) = &u.last_name {
if !last.is_empty() {
if !name.is_empty() {
name.push(' ');
}
name.push_str(last);
}
}
if name.is_empty() {
name = u.username.clone().unwrap_or_default();
}
name
});
let is_dm = Some(msg.chat.is_private());
let source_msg_id = msg.id;
let thread_id = msg.thread_id;
let last_sent: Arc<std::sync::Mutex<Option<MessageId>>> = Arc::new(std::sync::Mutex::new(None));
let bot_clone = bot.clone();
let sent_for_reply = Arc::clone(&last_sent);
let reply_fn: crate::ctx::ReplyFn = Box::new(move |reply: Reply| {
let bot = bot_clone.clone();
let sent = Arc::clone(&sent_for_reply);
Box::pin(async move {
let id = send_tg_message(&bot, chat_id, Some(source_msg_id), thread_id, reply).await?;
if let Ok(mut slot) = sent.lock() {
*slot = Some(id);
}
Ok(())
})
});
let bot_for_edit = bot.clone();
let sent_for_edit = Arc::clone(&last_sent);
let edit_fn: crate::ctx::EditFn = Arc::new(move |reply: Reply| {
let bot = bot_for_edit.clone();
let sent = Arc::clone(&sent_for_edit);
Box::pin(async move {
let target = sent.lock().ok().and_then(|slot| *slot);
match target {
Some(msg_id) => edit_tg_message(&bot, chat_id, msg_id, reply).await,
None => {
let id = send_tg_message(&bot, chat_id, Some(source_msg_id), thread_id, reply)
.await?;
if let Ok(mut slot) = sent.lock() {
*slot = Some(id);
}
Ok(())
}
}
})
});
let ctx = Ctx::new_with_edit(
PlatformKind::Telegram,
chat_id.0.to_string(),
user_id,
text,
reply_fn,
is_dm,
None,
Some(edit_fn),
)
.with_lookups(
None,
None,
chatinfo_lookup(bot.clone(), msg.chat.id, msg.chat.is_private()),
)
.with_typing(typing_lookup(bot.clone(), msg.chat.id, thread_id))
.with_temp_reply(temp_reply_lookup(bot.clone(), msg.chat.id, thread_id))
.with_user_name(user_name)
.with_incoming_image(
image_file_id.is_some(),
image_file_id.map(|id| image_lookup(bot.clone(), id)),
)
.with_reply_to_bot(
msg.reply_to_message()
.and_then(|r| r.from.as_ref())
.map(|u| Some(u.id.0) == self_id)
.unwrap_or(false),
);
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 user_name = {
let u = &q.from;
let mut name = u.first_name.clone();
if let Some(last) = &u.last_name {
if !last.is_empty() {
if !name.is_empty() {
name.push(' ');
}
name.push_str(last);
}
}
if name.is_empty() {
name = u.username.clone().unwrap_or_default();
}
Some(name)
};
let data = q.data.clone().unwrap_or_default();
let is_private = maybe_msg.chat().is_private();
let is_dm = Some(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
.map(|_| ())
})
});
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),
)
.with_lookups(
None,
None,
chatinfo_lookup(bot.clone(), chat_id, is_private),
)
.with_typing(typing_lookup(bot.clone(), chat_id, thread_id))
.with_temp_reply(temp_reply_lookup(bot.clone(), chat_id, thread_id))
.with_incoming_image(false, None)
.with_user_name(user_name);
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,
}
}
const TG_LIMIT: usize = 4096;
async fn send_tg_message(
bot: &teloxide::Bot,
chat_id: ChatId,
reply_to: Option<MessageId>,
thread_id: Option<ThreadId>,
reply: Reply,
) -> Result<MessageId> {
if reply.get_attachment().is_some() {
return send_tg_media(bot, chat_id, reply_to, thread_id, &reply).await;
}
let (body, use_html) = render_for_telegram(&reply);
if body.chars().count() > TG_LIMIT {
let plain = reply.get_text();
let source = if plain.is_empty() { &body } else { plain };
let parts = crate::util::split_chunks(source, TG_LIMIT);
let last = parts.len().saturating_sub(1);
let mut last_id = None;
for (i, part) in parts.iter().enumerate() {
let mut req = bot.send_message(chat_id, part);
if i == 0 {
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 i == last {
if let Some(kb) = reply.get_keyboard() {
req = req.reply_markup(to_tg_markup(kb));
}
}
let sent = req.await.map_err(|e| Error::platform("telegram", e))?;
last_id = Some(sent.id);
}
return last_id.ok_or_else(|| Error::platform("telegram", "nothing to send"));
}
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));
}
let sent = req.await.map_err(|e| Error::platform("telegram", e))?;
Ok(sent.id)
}
async fn send_tg_media(
bot: &teloxide::Bot,
chat_id: ChatId,
reply_to: Option<MessageId>,
thread_id: Option<ThreadId>,
reply: &Reply,
) -> Result<MessageId> {
let Some((bytes, name, kind)) = reply.get_attachment() else {
return Err(Error::platform("telegram", "no attachment to send"));
};
crate::keyboard::check_attachment_size(bytes, kind)?;
let file = InputFile::memory(bytes.to_vec()).file_name(name.to_owned());
const TG_CAPTION_LIMIT: usize = 1024;
let (body, use_html) = render_for_telegram(reply);
let (caption, overflow, caption_html) = if body.chars().count() > TG_CAPTION_LIMIT {
let plain = reply.get_text();
let source = if plain.is_empty() { &body } else { plain };
let mut parts = crate::util::split_chunks(source, TG_CAPTION_LIMIT);
let first = if parts.is_empty() {
String::new()
} else {
parts.remove(0)
};
(first, parts, false)
} else {
(body, Vec::new(), use_html)
};
macro_rules! send_with_extras {
($req:expr) => {{
let mut req = $req;
if !caption.is_empty() {
req = req.caption(caption.clone());
if caption_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))?
}};
}
let sent = match kind {
AttachmentKind::Photo => send_with_extras!(bot.send_photo(chat_id, file)),
AttachmentKind::Video => send_with_extras!(bot.send_video(chat_id, file)),
AttachmentKind::Audio => send_with_extras!(bot.send_audio(chat_id, file)),
};
let mut last_id = sent.id;
for part in overflow {
let mut req = bot.send_message(chat_id, part);
if let Some(tid) = thread_id {
req = req.message_thread_id(tid);
}
let sent = req.await.map_err(|e| Error::platform("telegram", e))?;
last_id = sent.id;
}
Ok(last_id)
}
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 (body, use_html) = if body.chars().count() > TG_LIMIT {
let plain = reply.get_text();
let source = if plain.is_empty() { &body } else { plain };
(crate::util::truncate_chunk(source, TG_LIMIT), false)
} else {
(body, use_html)
};
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) {
if reply.is_raw() {
let mut out = reply.get_text().to_owned();
if let Some(em) = reply.get_embed() {
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(&raw_embed_text(em));
}
return (out, false);
}
let Some(em) = reply.get_embed() else {
return (md_to_tg(reply.get_text()), true);
};
let mut out = String::new();
if let Some(title) = em.get_title() {
let rendered = md_to_tg(title);
match em.get_url() {
Some(u) => out.push_str(&format!(
"<b><a href=\"{}\">{rendered}</a></b>\n",
html_escape(u)
)),
None => out.push_str(&format!("<b>{rendered}</b>\n")),
}
}
if let Some(desc) = em.get_description() {
out.push_str(&md_to_tg(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",
md_to_tg(f.name()),
md_to_tg(f.value())
));
}
}
if let Some(foot) = em.get_footer() {
out.push_str(&format!("\n<i>{}</i>", md_to_tg(foot)));
}
if let Some(img) = em.get_image() {
out.push_str(&format!("\n\n{}", img));
}
if !reply.get_text().is_empty() {
let head = md_to_tg(reply.get_text());
out = format!("{head}\n\n{out}");
}
while out.ends_with(|c: char| c.is_whitespace()) {
out.pop();
}
(out, true)
}
fn raw_embed_text(em: &crate::keyboard::Embed) -> String {
let mut out = String::new();
if let Some(t) = em.get_title() {
out.push_str(t);
out.push('\n');
}
if let Some(d) = em.get_description() {
out.push_str(d);
out.push('\n');
}
for f in em.get_fields() {
out.push_str(&format!("{}\n{}\n", f.name(), f.value()));
}
if let Some(foot) = em.get_footer() {
out.push_str(&format!("\n{foot}"));
}
if let Some(img) = em.get_image() {
out.push_str(&format!("\n\n{img}"));
}
out.trim_end().to_owned()
}
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_str("""),
_ => out.push(c),
}
}
out
}
fn md_to_tg(input: &str) -> String {
let input: String = input.chars().filter(|&c| c != '\u{0}').collect();
let mut spans: Vec<String> = Vec::new();
let mut stage = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '`' {
let mut span = String::new();
let mut closed = false;
for cc in chars.by_ref() {
if cc == '`' {
closed = true;
break;
}
span.push(cc);
}
if closed {
stage.push('\u{0}');
stage.push_str(&spans.len().to_string());
stage.push('\u{0}');
spans.push(format!("<code>{}</code>", html_escape(&span)));
} else {
stage.push('`');
stage.push_str(&span);
}
} else {
stage.push(c);
}
}
let escaped = html_escape(&stage);
let linked = render_links(&escaped, &mut spans);
let tri = replace_emphasis(&linked, "***", "<b><i>", "</i></b>", false);
let bolded = replace_emphasis(&tri, "**", "<b>", "</b>", false);
let italic_star = replace_emphasis(&bolded, "*", "<i>", "</i>", false);
let mut result = replace_emphasis(&italic_star, "_", "<i>", "</i>", true);
for (i, span) in spans.iter().enumerate() {
let marker = format!("\u{0}{i}\u{0}");
result = result.replace(&marker, span);
}
result
}
fn replace_emphasis(s: &str, delim: &str, open: &str, close: &str, word_boundary: bool) -> String {
let chars: Vec<char> = s.chars().collect();
let dchars: Vec<char> = delim.chars().collect();
let dlen = dchars.len();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < chars.len() {
if chars[i..].starts_with(&dchars[..]) {
let after = chars.get(i + dlen).copied();
let opens = matches!(after, Some(c) if !c.is_whitespace() && c != dchars[0])
&& (!word_boundary
|| i == 0
|| chars
.get(i - 1)
.map(|c| !c.is_alphanumeric())
.unwrap_or(true));
if opens {
let mut j = i + dlen;
let mut found = None;
while j + dlen <= chars.len() {
if chars[j..].starts_with(&dchars[..])
&& chars
.get(j - 1)
.map(|c| !c.is_whitespace() && *c != dchars[0])
.unwrap_or(false)
&& (!word_boundary
|| chars
.get(j + dlen)
.map(|c| !c.is_alphanumeric())
.unwrap_or(true))
{
found = Some(j);
break;
}
j += 1;
}
if let Some(end) = found {
out.push_str(open);
out.extend(&chars[i + dlen..end]);
out.push_str(close);
i = end + dlen;
continue;
}
}
}
out.push(chars[i]);
i += 1;
}
out
}
fn render_links(s: &str, spans: &mut Vec<String>) -> String {
let bytes: Vec<char> = s.chars().collect();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == '[' {
if let Some((text, url, next)) = parse_link(&bytes, i) {
out.push('\u{0}');
out.push_str(&spans.len().to_string());
out.push('\u{0}');
spans.push(format!("<a href=\"{url}\">{text}</a>"));
i = next;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
out
}
fn parse_link(bytes: &[char], start: usize) -> Option<(String, String, usize)> {
let close_text = bytes[start..].iter().position(|&c| c == ']')? + start;
if bytes.get(close_text + 1) != Some(&'(') {
return None;
}
let close_url = bytes[close_text + 2..].iter().position(|&c| c == ')')? + close_text + 2;
let text: String = bytes[start + 1..close_text].iter().collect();
let url: String = bytes[close_text + 2..close_url].iter().collect();
Some((text, url, close_url + 1))
}
fn to_tg_markup(kb: &crate::keyboard::Keyboard) -> InlineKeyboardMarkup {
let rows: Vec<Vec<InlineKeyboardButton>> = kb
.rows()
.iter()
.map(|row| {
row.iter()
.filter_map(|btn| match &btn.kind {
ButtonKind::Callback(id) => {
if id.len() > 64 {
tracing::warn!(
callback = %id,
len = id.len(),
"telegram: callback data exceeds 64 bytes and will be rejected"
);
}
Some(InlineKeyboardButton::callback(
btn.label().to_owned(),
id.clone(),
))
}
ButtonKind::Url(url) => Some(InlineKeyboardButton::url(
btn.label().to_owned(),
url::Url::parse(url)
.unwrap_or_else(|_| url::Url::parse("https://fouko.xyz").unwrap()),
)),
ButtonKind::WebApp(url) => match url::Url::parse(url) {
Ok(parsed) => Some(InlineKeyboardButton::web_app(
btn.label().to_owned(),
teloxide::types::WebAppInfo { url: parsed },
)),
Err(e) => {
tracing::warn!(
url = %url,
error = %e,
"telegram: invalid web_app url, button dropped"
);
None
}
},
})
.collect()
})
.collect();
InlineKeyboardMarkup::new(rows)
}
fn is_valid_tg_command(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 32
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
fn truncate_chars(s: &str, max_chars: usize) -> String {
s.chars().take(max_chars).collect()
}
fn chatinfo_lookup(bot: teloxide::Bot, chat_id: ChatId, is_private: bool) -> Option<ChatInfoFn> {
Some(Arc::new(move || {
let bot = bot.clone();
Box::pin(async move {
let chat = bot
.get_chat(chat_id)
.await
.map_err(|e| Error::platform("telegram", e))?;
let member_count = if is_private {
None
} else {
bot.get_chat_member_count(chat_id)
.await
.ok()
.map(|c| c as u64)
};
let title = chat.title().map(|s| s.to_owned());
let description = chat.description().map(|s| s.to_owned());
Ok(ChatInfo {
id: chat_id.0.to_string(),
title,
member_count,
icon_url: None,
description,
is_private,
})
})
}))
}
fn typing_lookup(
bot: teloxide::Bot,
chat_id: ChatId,
thread_id: Option<ThreadId>,
) -> Option<crate::ctx::TypingFn> {
Some(Arc::new(move || {
let bot = bot.clone();
Box::pin(async move {
let mut req = bot.send_chat_action(chat_id, teloxide::types::ChatAction::Typing);
if let Some(tid) = thread_id {
req = req.message_thread_id(tid);
}
req.await.map_err(|e| Error::platform("telegram", e))?;
Ok(())
})
}))
}
fn tg_display_name(chat: &teloxide::types::Chat) -> String {
format_tg_name(
chat.first_name(),
chat.last_name(),
chat.username(),
chat.id.0,
)
}
fn format_tg_name(
first: Option<&str>,
last: Option<&str>,
username: Option<&str>,
id: i64,
) -> String {
let mut name = String::new();
if let Some(first) = first {
name.push_str(first);
}
if let Some(last) = last {
if !last.is_empty() {
if !name.is_empty() {
name.push(' ');
}
name.push_str(last);
}
}
match username {
Some(u) if name.is_empty() => format!("@{u}"),
Some(u) => format!("{name} (@{u})"),
None if name.is_empty() => id.to_string(),
None => name,
}
}
const MAX_INCOMING_IMAGE_BYTES: usize = 10 * 1024 * 1024;
fn tg_image_file_id(msg: &Message) -> Option<String> {
if let Some(sizes) = msg.photo() {
if let Some(largest) = sizes.last() {
return Some(largest.file.id.clone());
}
}
if let Some(doc) = msg.document() {
let is_image = doc
.mime_type
.as_ref()
.map(|m| m.type_() == "image")
.unwrap_or(false);
if is_image {
return Some(doc.file.id.clone());
}
}
None
}
fn image_lookup(bot: teloxide::Bot, file_id: String) -> crate::ctx::ImageFn {
Arc::new(move || {
let bot = bot.clone();
let file_id = file_id.clone();
Box::pin(async move {
let file = bot
.get_file(file_id)
.await
.map_err(|e| Error::platform("telegram", e))?;
if file.meta.size as usize > MAX_INCOMING_IMAGE_BYTES {
return Err(Error::platform("telegram", "incoming image too large"));
}
let url = format!(
"https://api.telegram.org/file/bot{}/{}",
bot.token(),
file.path
);
let bytes = download_capped(bot.client(), &url, MAX_INCOMING_IMAGE_BYTES)
.await
.map_err(|e| Error::platform("telegram", e))?;
Ok(Some(bytes))
})
})
}
async fn download_capped(
client: &reqwest::Client,
url: &str,
cap: usize,
) -> std::result::Result<Vec<u8>, String> {
let resp = client
.get(url)
.send()
.await
.map_err(|e| format!("image download failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("image download HTTP {}", resp.status().as_u16()));
}
let mut resp = resp;
let mut bytes = Vec::new();
while let Some(chunk) = resp
.chunk()
.await
.map_err(|e| format!("image download failed: {e}"))?
{
if bytes.len() + chunk.len() > cap {
return Err("incoming image too large".to_owned());
}
bytes.extend_from_slice(&chunk);
}
Ok(bytes)
}
async fn drop_pending_updates(bot: &teloxide::Bot) -> Result<()> {
use teloxide::payloads::GetUpdatesSetters;
let latest = bot
.get_updates()
.offset(-1)
.timeout(0)
.await
.map_err(|e| Error::platform("telegram", e))?;
if let Some(last) = latest.last() {
let next = i64::from(last.id.0).saturating_add(1);
let offset = i32::try_from(next).unwrap_or(i32::MAX);
let _ = bot
.get_updates()
.offset(offset)
.timeout(0)
.await
.map_err(|e| Error::platform("telegram", e))?;
}
Ok(())
}
fn temp_reply_lookup(
bot: teloxide::Bot,
chat_id: ChatId,
thread_id: Option<ThreadId>,
) -> Option<crate::ctx::TempReplyFn> {
Some(Arc::new(move |reply: Reply, secs: u64| {
let bot = bot.clone();
Box::pin(async move {
let sent = if let Some((bytes, name, kind)) = reply.get_attachment() {
crate::keyboard::check_attachment_size(bytes, kind)?;
let file = InputFile::memory(bytes.to_vec()).file_name(name.to_owned());
let (body, use_html) = render_for_telegram(&reply);
let caption: String = body.chars().take(1024).collect();
macro_rules! send_media {
($req:expr) => {{
let mut req = $req;
if !caption.is_empty() {
req = req.caption(caption.clone());
if use_html {
req = req.parse_mode(ParseMode::Html);
}
}
if let Some(tid) = thread_id {
req = req.message_thread_id(tid);
}
req.await.map_err(|e| Error::platform("telegram", e))?
}};
}
match kind {
AttachmentKind::Photo => send_media!(bot.send_photo(chat_id, file)),
AttachmentKind::Video => send_media!(bot.send_video(chat_id, file)),
AttachmentKind::Audio => send_media!(bot.send_audio(chat_id, file)),
}
} else {
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(tid) = thread_id {
req = req.message_thread_id(tid);
}
req.await.map_err(|e| Error::platform("telegram", e))?
};
let msg_id = sent.id;
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
let _ = bot.delete_message(chat_id, msg_id).await;
});
Ok(())
})
}))
}
#[cfg(test)]
mod tests {
use super::{format_tg_name, md_to_tg};
#[test]
fn code_span() {
assert_eq!(md_to_tg("run `/help` now"), "run <code>/help</code> now");
}
#[test]
fn bold_and_italic() {
assert_eq!(md_to_tg("**hi** and *there*"), "<b>hi</b> and <i>there</i>");
assert_eq!(md_to_tg("_stress_"), "<i>stress</i>");
}
#[test]
fn html_is_escaped() {
assert_eq!(md_to_tg("a < b & c"), "a < b & c");
}
#[test]
fn code_contents_are_literal() {
assert_eq!(md_to_tg("`**x** <b>`"), "<code>**x** <b></code>");
}
#[test]
fn link() {
assert_eq!(
md_to_tg("see [site](https://x.io)"),
"see <a href=\"https://x.io\">site</a>"
);
}
#[test]
fn unmatched_markers_stay_literal() {
assert_eq!(md_to_tg("2 * 3 = 6"), "2 * 3 = 6");
assert_eq!(md_to_tg("a `code"), "a `code");
}
#[test]
fn bold_italic_nests_validly() {
assert_eq!(md_to_tg("***x***"), "<b><i>x</i></b>");
assert_eq!(
md_to_tg("say ***hi there*** now"),
"say <b><i>hi there</i></b> now"
);
}
#[test]
fn link_with_underscores_survives() {
assert_eq!(
md_to_tg("[x](https://a.io/some_path_here)"),
"<a href=\"https://a.io/some_path_here\">x</a>"
);
}
#[test]
fn snake_case_is_not_italic() {
assert_eq!(md_to_tg("snake_case_word"), "snake_case_word");
assert_eq!(
md_to_tg("_stress_ but keep snake_case"),
"<i>stress</i> but keep snake_case"
);
}
#[test]
fn spaced_stars_are_not_italic() {
assert_eq!(md_to_tg("2 * 3 * 4"), "2 * 3 * 4");
}
#[test]
fn quote_in_url_is_escaped() {
assert_eq!(
md_to_tg("[x](https://a.io/?q=\"y\")"),
"<a href=\"https://a.io/?q="y"\">x</a>"
);
}
#[test]
fn nul_bytes_in_input_are_stripped() {
assert_eq!(md_to_tg("a\u{0}0\u{0}b `c`"), "a0b <code>c</code>");
}
#[test]
fn tg_name_full() {
assert_eq!(
format_tg_name(Some("Ivan"), Some("Petrov"), Some("ivan"), 1),
"Ivan Petrov (@ivan)"
);
}
#[test]
fn tg_name_no_username() {
assert_eq!(
format_tg_name(Some("Ivan"), Some("Petrov"), None, 1),
"Ivan Petrov"
);
assert_eq!(format_tg_name(Some("Ivan"), None, None, 1), "Ivan");
}
#[test]
fn tg_name_username_only() {
assert_eq!(format_tg_name(None, None, Some("ivan"), 1), "@ivan");
}
#[test]
fn tg_name_falls_back_to_id() {
assert_eq!(format_tg_name(None, None, None, 42), "42");
assert_eq!(format_tg_name(Some(""), Some(""), None, 42), "42");
}
}