use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use bytes::Bytes;
use cratefield_core::{Clock, HttpClient, retry_after};
use http::header::CONTENT_TYPE;
use http::{HeaderMap, Method, Request, StatusCode};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value as Json;
const DEFAULT_BASE: &str = "https://api.telegram.org";
const MAX_CALLBACK_DATA_BYTES: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineButton {
pub text: String,
pub kind: ButtonKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ButtonKind {
Callback(String),
Url(String),
}
impl InlineButton {
pub fn callback(
text: impl Into<String>,
data: impl Into<String>,
) -> Result<Self, TelegramError> {
let button = Self {
text: text.into(),
kind: ButtonKind::Callback(data.into()),
};
button.validate()?;
Ok(button)
}
#[must_use]
pub fn url(text: impl Into<String>, url: impl Into<String>) -> Self {
Self {
text: text.into(),
kind: ButtonKind::Url(url.into()),
}
}
fn validate(&self) -> Result<(), TelegramError> {
if let ButtonKind::Callback(data) = &self.kind {
let size = data.len();
if !(1..=MAX_CALLBACK_DATA_BYTES).contains(&size) {
return Err(TelegramError::Invalid(format!(
"callback_data must be 1..={MAX_CALLBACK_DATA_BYTES} bytes, got {size}"
)));
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct OutgoingMessage {
pub chat_id: i64,
pub text: String,
pub buttons: Vec<Vec<InlineButton>>,
pub disable_link_preview: bool,
}
impl OutgoingMessage {
#[must_use]
pub fn new(chat_id: i64, text: impl Into<String>) -> Self {
Self {
chat_id,
text: text.into(),
buttons: Vec::new(),
disable_link_preview: false,
}
}
#[must_use]
pub fn row(mut self, buttons: Vec<InlineButton>) -> Self {
self.buttons.push(buttons);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SentMessage {
pub chat_id: i64,
pub message_id: i64,
}
#[async_trait]
pub trait TelegramBot: Send + Sync {
async fn send_message(&self, message: &OutgoingMessage) -> Result<SentMessage, TelegramError>;
async fn edit_message(
&self,
chat_id: i64,
message_id: i64,
text: &str,
buttons: &[Vec<InlineButton>],
) -> Result<(), TelegramError>;
async fn delete_message(&self, chat_id: i64, message_id: i64) -> Result<(), TelegramError>;
async fn answer_callback(
&self,
callback_query_id: &str,
text: Option<&str>,
) -> Result<(), TelegramError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TelegramError {
RateLimited {
retry_after: Duration,
},
Unauthorized,
Forbidden(String),
Invalid(String),
Transient(String),
}
impl TelegramError {
#[must_use]
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::RateLimited { retry_after } => Some(*retry_after),
_ => None,
}
}
#[must_use]
pub fn is_transient(&self) -> bool {
matches!(self, Self::RateLimited { .. } | Self::Transient(_))
}
}
impl fmt::Display for TelegramError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::RateLimited { retry_after } => {
write!(
f,
"telegram rate limited the call; retry after {retry_after:?}"
)
}
Self::Unauthorized => f.write_str("telegram rejected the bot token (401)"),
Self::Forbidden(description) => {
write!(f, "telegram refused the call (403): {description}")
}
Self::Invalid(description) => write!(f, "telegram rejected the call: {description}"),
Self::Transient(detail) => write!(f, "telegram call failed transiently: {detail}"),
}
}
}
impl std::error::Error for TelegramError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RateLimits {
pub global_per_sec: u32,
pub private_chat_per_sec: u32,
pub group_per_min: u32,
}
impl Default for RateLimits {
fn default() -> Self {
Self {
global_per_sec: 30,
private_chat_per_sec: 1,
group_per_min: 20,
}
}
}
struct Bucket {
capacity: u128,
tokens: u128,
last_nanos: Option<u128>,
}
const NANOS_PER_TOKEN: u128 = 1_000_000_000;
const CHAT_BUCKETS_HIGH_WATER: usize = 1024;
const BUCKET_IDLE_NANOS: u128 = 120 * NANOS_PER_TOKEN;
impl Bucket {
fn new(capacity: u32) -> Self {
let capacity = u128::from(capacity) * NANOS_PER_TOKEN;
Self {
capacity,
tokens: capacity,
last_nanos: None,
}
}
fn untake(&mut self) {
self.tokens = self.capacity.min(self.tokens + NANOS_PER_TOKEN);
}
fn take(&mut self, now_nanos: u128, per_sec: (u64, u64)) -> Option<Duration> {
if per_sec.0 == 0 {
return Some(Duration::MAX); }
let units = u128::from(per_sec.0);
let last = self.last_nanos.unwrap_or(now_nanos);
let elapsed = now_nanos.saturating_sub(last);
self.last_nanos = Some(now_nanos);
let refill = ceil_div(elapsed.saturating_mul(units), u128::from(per_sec.1));
self.tokens = self.capacity.min(self.tokens.saturating_add(refill));
if self.tokens >= NANOS_PER_TOKEN {
self.tokens -= NANOS_PER_TOKEN;
return None;
}
let missing = NANOS_PER_TOKEN - self.tokens;
Some(duration_from_nanos(ceil_div(
missing.saturating_mul(u128::from(per_sec.1)),
units,
)))
}
}
fn ceil_div(numerator: u128, denominator: u128) -> u128 {
numerator / denominator + u128::from(!numerator.is_multiple_of(denominator))
}
fn duration_from_nanos(nanos: u128) -> Duration {
Duration::from_nanos(u64::try_from(nanos).unwrap_or(u64::MAX))
}
struct RateState {
global: Bucket,
chats: HashMap<i64, Bucket>,
not_before: HashMap<i64, u128>,
}
impl RateState {
fn new(limits: &RateLimits) -> Self {
Self {
global: Bucket::new(limits.global_per_sec),
chats: HashMap::new(),
not_before: HashMap::new(),
}
}
fn prune(&mut self, now: u128) {
if self.chats.len() < CHAT_BUCKETS_HIGH_WATER {
return;
}
self.chats.retain(|chat_id, bucket| {
if self
.not_before
.get(chat_id)
.is_some_and(|&until| now < until)
{
return true;
}
bucket
.last_nanos
.is_none_or(|last| now.saturating_sub(last) < BUCKET_IDLE_NANOS)
});
self.not_before.retain(|_chat_id, until| now < *until);
}
}
#[allow(clippy::disallowed_types)]
type Guarded<T> = std::sync::Mutex<T>;
pub struct HttpTelegramBot {
http: Arc<dyn HttpClient>,
clock: Arc<dyn Clock>,
token: String,
base: Option<String>,
limits: RateLimits,
budget: Guarded<RateState>,
}
impl fmt::Debug for HttpTelegramBot {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HttpTelegramBot")
.field("base", &self.base)
.field("bot_token", &"[redacted]")
.finish_non_exhaustive()
}
}
impl HttpTelegramBot {
#[must_use]
pub fn new(
http: Arc<dyn HttpClient>,
clock: Arc<dyn Clock>,
bot_token: impl Into<String>,
) -> Self {
let limits = RateLimits::default();
Self {
http,
clock,
token: bot_token.into(),
base: None,
limits,
budget: Guarded::new(RateState::new(&limits)),
}
}
#[must_use]
pub fn with_base(mut self, base_url: impl Into<String>) -> Self {
self.base = Some(base_url.into());
self
}
#[must_use]
pub fn with_limits(mut self, limits: RateLimits) -> Self {
self.limits = limits;
self.budget = Guarded::new(RateState::new(&limits));
self
}
pub async fn set_webhook(
&self,
url: &str,
secret_token: &str,
allowed_updates: &[&str],
) -> Result<(), TelegramError> {
if !url.is_empty() {
let localhost =
url.starts_with("http://localhost") || url.starts_with("http://127.0.0.1");
if !url.starts_with("https://") && !localhost {
return Err(TelegramError::Invalid(
"webhook url must be https (http only for localhost)".to_owned(),
));
}
if !crate::webhook::is_valid_secret(secret_token) {
return Err(TelegramError::Invalid(
"webhook secret token must be 1-256 chars of A-Z a-z 0-9 '_' or '-'".to_owned(),
));
}
}
let wire = SetWebhookWire {
url,
secret_token,
allowed_updates,
};
self.call("setWebhook", None, &wire).await.map(|_| ())
}
fn acquire(&self, chat: Option<i64>) -> Result<(), TelegramError> {
let now = self.now_nanos();
let mut state = self.budget.lock().expect("telegram rate-limit lock");
state.prune(now);
if let Some(chat_id) = chat
&& let Some(&until) = state.not_before.get(&chat_id)
&& now < until
{
return Err(TelegramError::RateLimited {
retry_after: duration_from_nanos(until - now),
});
}
if self.limits.global_per_sec > 0 {
let wait = state
.global
.take(now, (u64::from(self.limits.global_per_sec), 1));
if let Some(retry_after) = wait {
return Err(TelegramError::RateLimited { retry_after });
}
}
if let Some(chat_id) = chat {
let (rate, capacity) = if chat_id < 0 {
(
(u64::from(self.limits.group_per_min), 60),
self.limits.group_per_min,
)
} else {
(
(u64::from(self.limits.private_chat_per_sec), 1),
self.limits.private_chat_per_sec,
)
};
if capacity > 0 {
let bucket = state
.chats
.entry(chat_id)
.or_insert_with(|| Bucket::new(capacity));
if let Some(retry_after) = bucket.take(now, rate) {
if self.limits.global_per_sec > 0 {
state.global.untake();
}
return Err(TelegramError::RateLimited { retry_after });
}
}
}
Ok(())
}
fn note_not_before(&self, chat_id: i64, delay: Duration) {
let until = self.now_nanos().saturating_add(delay.as_nanos());
let mut state = self.budget.lock().expect("telegram rate-limit lock");
state
.not_before
.entry(chat_id)
.and_modify(|held| *held = (*held).max(until))
.or_insert(until);
}
fn now_nanos(&self) -> u128 {
u128::try_from(self.clock.now().unix_timestamp_nanos()).unwrap_or(0)
}
fn scrub(&self, text: &str) -> String {
if self.token.is_empty() {
return text.to_owned();
}
text.replace(&self.token, "[redacted]")
}
fn build_request(&self, method: &str, body: &Json) -> Result<Request<Bytes>, TelegramError> {
if !token_is_shape(&self.token) {
return Err(TelegramError::Invalid(
"bot token is not shaped like a Telegram token".to_owned(),
));
}
let base = self
.base
.as_deref()
.unwrap_or(DEFAULT_BASE)
.trim_end_matches('/');
let token = &self.token;
let url = format!("{base}/bot{token}/{method}");
let payload = serde_json::to_vec(body)
.map_err(|error| TelegramError::Transient(error.to_string()))?;
Request::builder()
.method(Method::POST)
.uri(url)
.header(CONTENT_TYPE, "application/json")
.body(Bytes::from(payload))
.map_err(|error| {
TelegramError::Invalid(self.scrub(&error.to_string()))
})
}
async fn call(
&self,
method: &str,
chat: Option<i64>,
body: &impl Serialize,
) -> Result<Envelope, TelegramError> {
let value = serde_json::to_value(body)
.map_err(|error| TelegramError::Transient(error.to_string()))?;
let request = self.build_request(method, &value)?;
self.acquire(chat)?;
tracing::debug!(method, chat = ?chat, "telegram Bot API call");
let response = self
.http
.send(request)
.await
.map_err(|error| TelegramError::Transient(self.scrub(&error.to_string())))?;
let (parts, bytes) = response.into_parts();
let envelope = serde_json::from_slice::<Envelope>(&bytes).ok();
self.settle(parts.status, &parts.headers, envelope, chat)
}
fn settle(
&self,
status: StatusCode,
headers: &HeaderMap,
envelope: Option<Envelope>,
chat: Option<i64>,
) -> Result<Envelope, TelegramError> {
let Some(envelope) = envelope else {
return Err(TelegramError::Transient(
"telegram returned an unparseable response body".to_owned(),
));
};
if status.is_success() && envelope.ok {
return Ok(envelope);
}
Err(self.api_error(status, headers, envelope, chat))
}
fn api_error(
&self,
status: StatusCode,
headers: &HeaderMap,
envelope: Envelope,
chat: Option<i64>,
) -> TelegramError {
let code = envelope.error_code.unwrap_or_else(|| status.as_u16());
let description = self.scrub(
envelope
.description
.as_deref()
.unwrap_or("telegram returned an error without a description"),
);
match code {
429 => {
let retry_after = envelope
.parameters
.and_then(|parameters| parameters.retry_after)
.map(Duration::from_secs)
.or_else(|| retry_after(headers, &*self.clock))
.unwrap_or_else(|| Duration::from_secs(1));
if let Some(chat_id) = chat {
self.note_not_before(chat_id, retry_after);
}
tracing::warn!(chat = ?chat, retry_after = ?retry_after, "telegram rate limited the bot");
TelegramError::RateLimited { retry_after }
}
401 => TelegramError::Unauthorized,
403 => TelegramError::Forbidden(description),
400..=499 => TelegramError::Invalid(description),
_ => TelegramError::Transient(description),
}
}
}
fn token_is_shape(token: &str) -> bool {
!token.is_empty()
&& token
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-'))
}
fn validate_text(text: &str) -> Result<(), TelegramError> {
if text.trim().is_empty() {
return Err(TelegramError::Invalid("message text is empty".to_owned()));
}
Ok(())
}
fn validate_buttons(buttons: &[Vec<InlineButton>]) -> Result<(), TelegramError> {
for row in buttons {
for button in row {
button.validate()?;
}
}
Ok(())
}
fn validate_outgoing(message: &OutgoingMessage) -> Result<(), TelegramError> {
validate_text(&message.text)?;
validate_buttons(&message.buttons)
}
fn deserialize_result<T: DeserializeOwned>(
bot: &HttpTelegramBot,
method: &str,
result: Option<Json>,
) -> Result<T, TelegramError> {
let value =
result.ok_or_else(|| TelegramError::Transient(format!("{method} returned no result")))?;
serde_json::from_value(value).map_err(|error| {
TelegramError::Transient(bot.scrub(&format!(
"{method} returned a result this adapter cannot read: {error}"
)))
})
}
#[derive(Serialize)]
struct WireButton<'a> {
text: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
callback_data: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<&'a str>,
}
#[derive(Serialize)]
struct WireKeyboard<'a> {
inline_keyboard: Vec<Vec<WireButton<'a>>>,
}
fn keyboard(buttons: &[Vec<InlineButton>]) -> Option<WireKeyboard<'_>> {
if buttons.is_empty() {
return None;
}
Some(WireKeyboard {
inline_keyboard: buttons
.iter()
.map(|row| {
row.iter()
.map(|button| match &button.kind {
ButtonKind::Callback(data) => WireButton {
text: &button.text,
callback_data: Some(data),
url: None,
},
ButtonKind::Url(url) => WireButton {
text: &button.text,
callback_data: None,
url: Some(url),
},
})
.collect()
})
.collect(),
})
}
#[derive(Serialize)]
struct WireLinkPreview {
is_disabled: bool,
}
#[derive(Serialize)]
struct SendMessageWire<'a> {
chat_id: i64,
text: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
reply_markup: Option<WireKeyboard<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
link_preview_options: Option<WireLinkPreview>,
}
#[derive(Serialize)]
struct EditMessageWire<'a> {
chat_id: i64,
message_id: i64,
text: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
reply_markup: Option<WireKeyboard<'a>>,
}
#[derive(Serialize)]
struct DeleteMessageWire {
chat_id: i64,
message_id: i64,
}
#[derive(Serialize)]
struct AnswerCallbackWire<'a> {
callback_query_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
text: Option<&'a str>,
}
#[derive(Serialize)]
struct SetWebhookWire<'a> {
url: &'a str,
secret_token: &'a str,
allowed_updates: &'a [&'a str],
}
#[derive(Deserialize, Default)]
struct Envelope {
#[serde(default)]
ok: bool,
#[serde(default)]
result: Option<Json>,
#[serde(default)]
error_code: Option<u16>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
parameters: Option<WireParameters>,
}
#[derive(Deserialize)]
struct WireParameters {
#[serde(default)]
retry_after: Option<u64>,
}
#[derive(Deserialize)]
struct SentWire {
message_id: i64,
chat: SentChatWire,
}
#[derive(Deserialize)]
struct SentChatWire {
id: i64,
}
#[async_trait]
impl TelegramBot for HttpTelegramBot {
async fn send_message(&self, message: &OutgoingMessage) -> Result<SentMessage, TelegramError> {
validate_outgoing(message)?;
let wire = SendMessageWire {
chat_id: message.chat_id,
text: &message.text,
reply_markup: keyboard(&message.buttons),
link_preview_options: message
.disable_link_preview
.then_some(WireLinkPreview { is_disabled: true }),
};
let envelope = self
.call("sendMessage", Some(message.chat_id), &wire)
.await?;
let sent: SentWire = deserialize_result(self, "sendMessage", envelope.result)?;
Ok(SentMessage {
chat_id: sent.chat.id,
message_id: sent.message_id,
})
}
async fn edit_message(
&self,
chat_id: i64,
message_id: i64,
text: &str,
buttons: &[Vec<InlineButton>],
) -> Result<(), TelegramError> {
validate_text(text)?;
validate_buttons(buttons)?;
let wire = EditMessageWire {
chat_id,
message_id,
text,
reply_markup: keyboard(buttons),
};
self.call("editMessageText", Some(chat_id), &wire)
.await
.map(|_| ())
}
async fn delete_message(&self, chat_id: i64, message_id: i64) -> Result<(), TelegramError> {
let wire = DeleteMessageWire {
chat_id,
message_id,
};
self.call("deleteMessage", Some(chat_id), &wire)
.await
.map(|_| ())
}
async fn answer_callback(
&self,
callback_query_id: &str,
text: Option<&str>,
) -> Result<(), TelegramError> {
let wire = AnswerCallbackWire {
callback_query_id,
text,
};
self.call("answerCallbackQuery", None, &wire)
.await
.map(|_| ())
}
}
#[cfg(test)]
mod tests {
use super::*;
use cratefield_core::HttpError;
use time::OffsetDateTime;
struct NeverHttp;
#[async_trait::async_trait]
impl HttpClient for NeverHttp {
async fn send(&self, _request: Request<Bytes>) -> Result<http::Response<Bytes>, HttpError> {
Err(HttpError::Transport("never sent".to_owned()))
}
}
struct TestClock(Guarded<OffsetDateTime>);
impl TestClock {
fn started_at(instant: OffsetDateTime) -> Arc<Self> {
Arc::new(Self(Guarded::new(instant)))
}
fn advance(&self, seconds: i64) {
*self.0.lock().expect("clock lock") += time::Duration::seconds(seconds);
}
}
impl Clock for TestClock {
fn now(&self) -> OffsetDateTime {
*self.0.lock().expect("clock lock")
}
}
fn epoch() -> OffsetDateTime {
OffsetDateTime::from_unix_timestamp(1_767_225_600).expect("a representable instant")
}
fn one_per_chat() -> RateLimits {
RateLimits {
global_per_sec: 0,
private_chat_per_sec: 1,
group_per_min: 20,
}
}
#[test]
fn idle_buckets_are_pruned_once_the_map_grows_past_the_high_water() {
let clock = TestClock::started_at(epoch());
let bot = HttpTelegramBot::new(Arc::new(NeverHttp), clock.clone(), "test-bot-token")
.with_limits(one_per_chat());
let high_water = i64::try_from(CHAT_BUCKETS_HIGH_WATER).expect("fits a chat id");
for chat in 0..high_water + 32 {
bot.acquire(Some(chat)).expect("each chat starts full");
}
assert!(
bot.budget.lock().expect("budget lock").chats.len() >= CHAT_BUCKETS_HIGH_WATER,
"the burst itself is kept: every bucket was just used"
);
clock.advance(121);
bot.acquire(Some(-1)).expect("the new chat is served");
let state = bot.budget.lock().expect("budget lock");
assert_eq!(
state.chats.len(),
1,
"every idle bucket from the burst was pruned"
);
assert!(state.not_before.is_empty());
}
#[test]
fn a_live_429_deadline_survives_the_sweep_and_an_expired_one_does_not() {
let clock = TestClock::started_at(epoch());
let bot = HttpTelegramBot::new(Arc::new(NeverHttp), clock.clone(), "test-bot-token")
.with_limits(one_per_chat());
bot.acquire(Some(7)).expect("chat 7 starts full");
bot.note_not_before(7, Duration::from_secs(10));
bot.note_not_before(8, Duration::from_secs(1));
let high_water = i64::try_from(CHAT_BUCKETS_HIGH_WATER).expect("fits a chat id");
for chat in 1000..1000 + high_water {
bot.acquire(Some(chat)).expect("each chat starts full");
}
clock.advance(1);
bot.acquire(Some(99_999)).expect("a fresh chat is served");
{
let state = bot.budget.lock().expect("budget lock");
assert!(
state.chats.contains_key(&7) && state.not_before.contains_key(&7),
"the chat under a live deadline is kept"
);
assert!(
!state.not_before.contains_key(&8),
"the expired deadline is garbage, collected"
);
}
let error = bot.acquire(Some(7)).expect_err("the deadline holds");
assert_eq!(
error.retry_after(),
Some(Duration::from_secs(9)),
"the surviving deadline still governs, at its remaining time"
);
}
}